From 7e16f500cb7bc0cfd8bafbf6bb1555704f771231 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Fri, 29 Apr 2022 12:13:34 +0200
Subject: chore: remove old pages, components, helpers and types

Since I'm using new components, I will also rewrite the GraphQL queries
so it is easier to start from scratch.
---
 src/pages/article/[slug].tsx | 291 -------------------------------------------
 1 file changed, 291 deletions(-)
 delete mode 100644 src/pages/article/[slug].tsx

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
deleted file mode 100644
index 27a6f7b..0000000
--- a/src/pages/article/[slug].tsx
+++ /dev/null
@@ -1,291 +0,0 @@
-import CommentForm from '@components/CommentForm/CommentForm';
-import CommentsList from '@components/CommentsList/CommentsList';
-import { getLayout } from '@components/Layouts/Layout';
-import PostFooter from '@components/PostFooter/PostFooter';
-import PostHeader from '@components/PostHeader/PostHeader';
-import Sidebar from '@components/Sidebar/Sidebar';
-import Spinner from '@components/Spinner/Spinner';
-import { Sharing, ToC } from '@components/Widgets';
-import {
-  getAllPostsSlug,
-  getCommentsByPostId,
-  getPostBySlug,
-} from '@services/graphql/queries';
-import styles from '@styles/pages/Page.module.scss';
-import { NextPageWithLayout } from '@ts/types/app';
-import { ArticleMeta, ArticleProps } from '@ts/types/articles';
-import { PrismDefaultPlugins, PrismPlugins } from '@ts/types/prism';
-import { settings } from '@utils/config';
-import { getFormattedPaths } from '@utils/helpers/format';
-import { loadTranslation } from '@utils/helpers/i18n';
-import { addPrismClasses } from '@utils/helpers/prism';
-import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next';
-import Head from 'next/head';
-import { useRouter } from 'next/router';
-import Script from 'next/script';
-import Prism from 'prismjs';
-import { ParsedUrlQuery } from 'querystring';
-import { useCallback, useEffect, useMemo } from 'react';
-import { useIntl } from 'react-intl';
-import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
-
-const SingleArticle: NextPageWithLayout<ArticleProps> = ({
-  comments,
-  post,
-}) => {
-  const intl = useIntl();
-  const router = useRouter();
-
-  const loadPrismPlugins = useCallback(
-    async (prismPlugins: (PrismDefaultPlugins | PrismPlugins)[]) => {
-      for (const plugin of prismPlugins) {
-        try {
-          if (plugin === 'color-scheme') {
-            await import(`@utils/plugins/prism-${plugin}`);
-          } else {
-            await import(`prismjs/plugins/${plugin}/prism-${plugin}.min.js`);
-
-            if (plugin === 'autoloader')
-              Prism.plugins.autoloader.languages_path = '/prism/';
-          }
-        } catch (error) {
-          console.error('Article: an error occurred with Prism.');
-          console.error(error);
-        }
-      }
-    },
-    []
-  );
-
-  const plugins: (PrismDefaultPlugins | PrismPlugins)[] = useMemo(
-    () => [
-      'autoloader',
-      'toolbar',
-      'show-language',
-      'copy-to-clipboard',
-      'color-scheme',
-      'command-line',
-      'line-numbers',
-      'match-braces',
-      'normalize-whitespace',
-    ],
-    []
-  );
-
-  useEffect(() => {
-    loadPrismPlugins(plugins).then(() => {
-      addPrismClasses();
-      Prism.highlightAll();
-    });
-  }, [plugins, loadPrismPlugins]);
-
-  if (router.isFallback) return <Spinner />;
-
-  const {
-    author,
-    commentCount,
-    content,
-    databaseId,
-    dates,
-    featuredImage,
-    info,
-    intro,
-    seo,
-    topics,
-    thematics,
-    title,
-  } = post;
-
-  const meta: ArticleMeta = {
-    author,
-    commentCount: commentCount || undefined,
-    dates,
-    readingTime: info.readingTime,
-    thematics,
-    wordsCount: info.wordsCount,
-  };
-
-  const articleUrl = `${settings.url}${router.asPath}`;
-
-  const webpageSchema: WebPage = {
-    '@id': `${articleUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${settings.url}/#breadcrumb` },
-    lastReviewed: dates.update,
-    name: seo.title,
-    description: seo.metaDesc,
-    reviewedBy: { '@id': `${settings.url}/#branding` },
-    url: `${articleUrl}`,
-    isPartOf: {
-      '@id': `${settings.url}`,
-    },
-  };
-
-  const blogSchema: Blog = {
-    '@id': `${settings.url}/#blog`,
-    '@type': 'Blog',
-    blogPost: { '@id': `${settings.url}/#article` },
-    isPartOf: {
-      '@id': `${articleUrl}`,
-    },
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-  };
-
-  const publicationDate = new Date(dates.publication);
-  const updateDate = new Date(dates.update);
-
-  const blogPostSchema: BlogPosting = {
-    '@id': `${settings.url}/#article`,
-    '@type': 'BlogPosting',
-    name: title,
-    description: intro,
-    articleBody: content,
-    author: { '@id': `${settings.url}/#branding` },
-    commentCount: commentCount || undefined,
-    copyrightYear: publicationDate.getFullYear(),
-    creator: { '@id': `${settings.url}/#branding` },
-    dateCreated: publicationDate.toISOString(),
-    dateModified: updateDate.toISOString(),
-    datePublished: publicationDate.toISOString(),
-    discussionUrl: `${articleUrl}/#comments`,
-    editor: { '@id': `${settings.url}/#branding` },
-    headline: title,
-    image: featuredImage?.sourceUrl,
-    inLanguage: settings.locales.defaultLocale,
-    isPartOf: {
-      '@id': `${settings.url}/blog`,
-    },
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${articleUrl}` },
-    thumbnailUrl: featuredImage?.sourceUrl,
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, blogSchema, blogPostSchema],
-  };
-
-  const copyText = intl.formatMessage({
-    defaultMessage: 'Copy',
-    description: 'Prism: copy button text (no clicked)',
-    id: '/ly3AC',
-  });
-  const copiedText = intl.formatMessage({
-    defaultMessage: 'Copied!',
-    description: 'Prism: copy button text (clicked)',
-    id: 'OV9r1K',
-  });
-  const errorText = intl.formatMessage({
-    defaultMessage: 'Use Ctrl+c to copy',
-    description: 'Prism: error text',
-    id: 'z9qkcQ',
-  });
-  const darkTheme = intl.formatMessage({
-    defaultMessage: 'Dark Theme 🌙',
-    description: 'Prism: toggle dark theme button text',
-    id: 'nFMdWI',
-  });
-  const lightTheme = intl.formatMessage({
-    defaultMessage: 'Light Theme 🌞',
-    description: 'Prism: toggle light theme button text',
-    id: 'Ua2g2p',
-  });
-
-  return (
-    <>
-      <Head>
-        <title>{seo.title}</title>
-        <meta name="description" content={seo.metaDesc} />
-        <meta property="og:url" content={`${articleUrl}`} />
-        <meta property="og:type" content="article" />
-        <meta property="og:title" content={title} />
-        <meta property="og:description" content={intro} />
-        <meta property="og:image" content={featuredImage?.sourceUrl} />
-        <meta property="og:image:alt" content={featuredImage?.altText} />
-      </Head>
-      <Script
-        id="schema-article"
-        type="application/ld+json"
-        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
-      />
-      <article
-        id="article"
-        className={styles.article}
-        data-prismjs-copy={copyText}
-        data-prismjs-copy-success={copiedText}
-        data-prismjs-copy-error={errorText}
-        data-prismjs-color-scheme-dark={darkTheme}
-        data-prismjs-color-scheme-light={lightTheme}
-      >
-        <PostHeader intro={intro} meta={meta} title={title} />
-        <Sidebar
-          position="left"
-          ariaLabel={intl.formatMessage({
-            defaultMessage: 'Table of Contents',
-            description: 'ArticlePage: ToC sidebar aria-label',
-            id: '9nhYRA',
-          })}
-        >
-          <ToC />
-        </Sidebar>
-        <div
-          className={styles.body}
-          dangerouslySetInnerHTML={{ __html: content }}
-        ></div>
-        <PostFooter topics={topics} />
-        <Sidebar
-          position="right"
-          ariaLabel={intl.formatMessage({
-            defaultMessage: 'Sidebar',
-            description: 'ArticlePage: right sidebar aria-label',
-            id: 'JeYOeA',
-          })}
-        >
-          <Sharing title={title} excerpt={intro} />
-        </Sidebar>
-        <section id="comments" className={styles.comments}>
-          <CommentsList articleId={databaseId} comments={comments} />
-          <CommentForm articleId={databaseId} />
-        </section>
-      </article>
-    </>
-  );
-};
-
-SingleArticle.getLayout = getLayout;
-
-interface PostParams extends ParsedUrlQuery {
-  slug: string;
-}
-
-export const getStaticProps: GetStaticProps = async (
-  context: GetStaticPropsContext
-) => {
-  const { locale } = context;
-  const translation = await loadTranslation(locale);
-  const { slug } = context.params as PostParams;
-  const post = await getPostBySlug(slug);
-  const comments = await getCommentsByPostId(post.databaseId);
-  const breadcrumbTitle = post.title;
-
-  return {
-    props: {
-      breadcrumbTitle,
-      comments,
-      post,
-      translation,
-    },
-  };
-};
-
-export const getStaticPaths: GetStaticPaths = async () => {
-  const allSlugs = await getAllPostsSlug();
-  const paths = getFormattedPaths(allSlugs);
-
-  return {
-    paths,
-    fallback: true,
-  };
-};
-
-export default SingleArticle;
-- 
cgit v1.2.3


From c95cce04393080a52a54191cff7be8fec68af4b0 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Sun, 15 May 2022 17:45:41 +0200
Subject: chore: add Article pages

---
 src/components/atoms/layout/notice.module.scss     |   1 -
 .../organisms/forms/comment-form.stories.tsx       |  30 +--
 .../organisms/forms/comment-form.test.tsx          |   7 +-
 src/components/organisms/forms/comment-form.tsx    |  19 +-
 .../organisms/layout/comment.stories.tsx           |  39 +++-
 src/components/organisms/layout/comment.test.tsx   |   6 +-
 src/components/organisms/layout/comment.tsx        |  14 +-
 .../organisms/layout/comments-list.stories.tsx     |  30 ++-
 .../organisms/layout/comments-list.test.tsx        |   6 +-
 src/components/organisms/layout/comments-list.tsx  |  10 +-
 .../templates/page/page-layout.stories.tsx         |   6 +-
 src/components/templates/page/page-layout.tsx      |  95 +++++++-
 src/pages/article/[slug].tsx                       | 251 +++++++++++++++++++++
 src/services/graphql/api.ts                        |  52 ++++-
 src/services/graphql/comments.mutation.ts          |  30 +++
 src/services/graphql/comments.ts                   | 102 +++++++++
 16 files changed, 634 insertions(+), 64 deletions(-)
 create mode 100644 src/pages/article/[slug].tsx
 create mode 100644 src/services/graphql/comments.mutation.ts
 create mode 100644 src/services/graphql/comments.ts

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/components/atoms/layout/notice.module.scss b/src/components/atoms/layout/notice.module.scss
index 38ec7ee..7fd972c 100644
--- a/src/components/atoms/layout/notice.module.scss
+++ b/src/components/atoms/layout/notice.module.scss
@@ -1,7 +1,6 @@
 @use "@styles/abstracts/functions" as fun;
 
 .wrapper {
-  width: max-content;
   padding: var(--spacing-2xs) var(--spacing-xs);
   border: fun.convert-px(2) solid;
   font-weight: bold;
diff --git a/src/components/organisms/forms/comment-form.stories.tsx b/src/components/organisms/forms/comment-form.stories.tsx
index f66d35c..8b11df7 100644
--- a/src/components/organisms/forms/comment-form.stories.tsx
+++ b/src/components/organisms/forms/comment-form.stories.tsx
@@ -1,13 +1,19 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CommentForm from './comment-form';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 /**
  * CommentForm - Storybook Meta
  */
 export default {
   title: 'Organisms/Forms',
   component: CommentForm,
+  args: {
+    saveComment,
+  },
   argTypes: {
     className: {
       control: {
@@ -35,6 +41,16 @@ export default {
         required: false,
       },
     },
+    parentId: {
+      control: {
+        type: null,
+      },
+      description: 'The parent id if it is a reply.',
+      type: {
+        name: 'number',
+        required: false,
+      },
+    },
     saveComment: {
       control: {
         type: null,
@@ -74,13 +90,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CommentForm>;
 
 const Template: ComponentStory<typeof CommentForm> = (args) => (
@@ -91,8 +100,3 @@ const Template: ComponentStory<typeof CommentForm> = (args) => (
  * Forms Stories - Comment
  */
 export const Comment = Template.bind({});
-Comment.args = {
-  saveComment: (reset: () => void) => {
-    reset();
-  },
-};
diff --git a/src/components/organisms/forms/comment-form.test.tsx b/src/components/organisms/forms/comment-form.test.tsx
index 0d387b5..c67ad6b 100644
--- a/src/components/organisms/forms/comment-form.test.tsx
+++ b/src/components/organisms/forms/comment-form.test.tsx
@@ -1,17 +1,20 @@
 import { render, screen } from '@test-utils';
 import CommentForm from './comment-form';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
 const title = 'Cum voluptas voluptatibus';
 
 describe('CommentForm', () => {
   it('renders a form', () => {
-    render(<CommentForm saveComment={() => null} />);
+    render(<CommentForm saveComment={saveComment} />);
     expect(screen.getByRole('form')).toBeInTheDocument();
   });
 
   it('renders an optional title', () => {
     render(
-      <CommentForm saveComment={() => null} title={title} titleLevel={2} />
+      <CommentForm saveComment={saveComment} title={title} titleLevel={2} />
     );
     expect(
       screen.getByRole('heading', { level: 2, name: title })
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
index d7cb0f5..9e0abdf 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -7,6 +7,14 @@ import { FC, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
 import styles from './comment-form.module.scss';
 
+export type CommentFormData = {
+  comment: string;
+  email: string;
+  name: string;
+  parentId?: number;
+  website?: string;
+};
+
 export type CommentFormProps = {
   /**
    * Set additional classnames to the form wrapper.
@@ -16,11 +24,15 @@ export type CommentFormProps = {
    * Pass a component to print a success/error message.
    */
   Notice?: ReactNode;
+  /**
+   * The comment parent id.
+   */
+  parentId?: number;
   /**
    * A callback function to save comment. It takes a function as parameter to
    * reset the form.
    */
-  saveComment: (reset: () => void) => void;
+  saveComment: (data: CommentFormData, reset: () => void) => Promise<void>;
   /**
    * The form title.
    */
@@ -34,6 +46,7 @@ export type CommentFormProps = {
 const CommentForm: FC<CommentFormProps> = ({
   className = '',
   Notice,
+  parentId,
   saveComment,
   title,
   titleLevel = 2,
@@ -95,7 +108,9 @@ const CommentForm: FC<CommentFormProps> = ({
    */
   const submitHandler = () => {
     setIsSubmitting(true);
-    saveComment(resetForm);
+    saveComment({ comment, email, name, parentId, website }, resetForm).then(
+      () => setIsSubmitting(false)
+    );
   };
 
   return (
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
index 3794b06..c31b77a 100644
--- a/src/components/organisms/layout/comment.stories.tsx
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -1,13 +1,19 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CommentComponent from './comment';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 /**
  * Comment - Storybook Meta
  */
 export default {
   title: 'Organisms/Layout',
   component: CommentComponent,
+  args: {
+    saveComment,
+  },
   argTypes: {
     author: {
       description: 'The author data.',
@@ -51,6 +57,29 @@ export default {
         required: true,
       },
     },
+    Notice: {
+      control: {
+        type: null,
+      },
+      description: 'A component to display a success or error message.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'function',
+        required: false,
+      },
+    },
+    parentId: {
+      control: {
+        type: null,
+      },
+      description: 'The parent id if it is a reply.',
+      type: {
+        name: 'number',
+        required: false,
+      },
+    },
     publication: {
       description: 'The publication date.',
       type: {
@@ -73,13 +102,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CommentComponent>;
 
 const Template: ComponentStory<typeof CommentComponent> = (args) => (
@@ -100,7 +122,6 @@ Comment.args = {
     'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.',
   id: 2,
   publication: '2021-04-03 23:04:24',
-  saveComment: () => null,
   // @ts-ignore - Needed because of the placeholder image.
   unoptimized: true,
 };
diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx
index 4961722..02a51dc 100644
--- a/src/components/organisms/layout/comment.test.tsx
+++ b/src/components/organisms/layout/comment.test.tsx
@@ -11,13 +11,15 @@ const content =
   'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.';
 const publication = '2021-04-03 23:04:24';
 const id = 5;
-
+const saveComment = async () => {
+  /** Do nothing. */
+};
 const data = {
   author,
   content,
   id,
   publication,
-  saveComment: () => null,
+  saveComment,
 };
 
 const formattedDate = getFormattedDate(publication);
diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx
index 248efc2..6df393b 100644
--- a/src/components/organisms/layout/comment.tsx
+++ b/src/components/organisms/layout/comment.tsx
@@ -25,7 +25,7 @@ export type CommentAuthor = {
   url?: string;
 };
 
-export type CommentProps = {
+export type CommentProps = Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
   /**
    * The comment author data.
    */
@@ -50,10 +50,6 @@ export type CommentProps = {
    * The comment date and time separated with a space.
    */
   publication: string;
-  /**
-   * A callback function to save comment form data.
-   */
-  saveComment: CommentFormProps['saveComment'];
 };
 
 /**
@@ -66,6 +62,7 @@ const Comment: FC<CommentProps> = ({
   canReply = true,
   content,
   id,
+  Notice,
   parentId,
   publication,
   saveComment,
@@ -169,7 +166,10 @@ const Comment: FC<CommentProps> = ({
           className={styles.date}
           groupClassName={styles.date__item}
         />
-        <div className={styles.body}>{content}</div>
+        <div
+          className={styles.body}
+          dangerouslySetInnerHTML={{ __html: content }}
+        />
         <footer className={styles.footer}>
           {canReply && (
             <Button kind="tertiary" onClick={() => setIsReplying(!isReplying)}>
@@ -180,6 +180,8 @@ const Comment: FC<CommentProps> = ({
       </article>
       {isReplying && (
         <CommentForm
+          Notice={Notice}
+          parentId={id as number}
           saveComment={saveComment}
           title={formTitle}
           className={`${styles.wrapper} ${styles['wrapper--form']}`}
diff --git a/src/components/organisms/layout/comments-list.stories.tsx b/src/components/organisms/layout/comments-list.stories.tsx
index 9edf368..4d95205 100644
--- a/src/components/organisms/layout/comments-list.stories.tsx
+++ b/src/components/organisms/layout/comments-list.stories.tsx
@@ -1,13 +1,19 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import CommentsListComponent, { Comment } from './comments-list';
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 /**
  * CommentsList - Storybook Meta
  */
 export default {
   title: 'Organisms/Layout/CommentsList',
   component: CommentsListComponent,
+  args: {
+    saveComment,
+  },
   argTypes: {
     comments: {
       control: {
@@ -30,6 +36,19 @@ export default {
         required: true,
       },
     },
+    Notice: {
+      control: {
+        type: null,
+      },
+      description: 'A component to display a success or error message.',
+      table: {
+        category: 'Options',
+      },
+      type: {
+        name: 'function',
+        required: false,
+      },
+    },
     saveComment: {
       control: {
         type: null,
@@ -44,13 +63,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof CommentsListComponent>;
 
 const Template: ComponentStory<typeof CommentsListComponent> = (args) => (
@@ -130,7 +142,6 @@ export const WithoutChildComments = Template.bind({});
 WithoutChildComments.args = {
   comments,
   depth: 0,
-  saveComment: () => null,
 };
 
 /**
@@ -140,5 +151,4 @@ export const WithChildComments = Template.bind({});
 WithChildComments.args = {
   comments,
   depth: 1,
-  saveComment: () => null,
 };
diff --git a/src/components/organisms/layout/comments-list.test.tsx b/src/components/organisms/layout/comments-list.test.tsx
index 542b1df..e135ec9 100644
--- a/src/components/organisms/layout/comments-list.test.tsx
+++ b/src/components/organisms/layout/comments-list.test.tsx
@@ -57,10 +57,14 @@ const comments: Comment[] = [
   },
 ];
 
+const saveComment = async () => {
+  /** Do nothing. */
+};
+
 describe('CommentsList', () => {
   it('renders a comments list', () => {
     render(
-      <CommentsList comments={comments} depth={1} saveComment={() => null} />
+      <CommentsList comments={comments} depth={1} saveComment={saveComment} />
     );
   });
 });
diff --git a/src/components/organisms/layout/comments-list.tsx b/src/components/organisms/layout/comments-list.tsx
index 03f508e..f04354c 100644
--- a/src/components/organisms/layout/comments-list.tsx
+++ b/src/components/organisms/layout/comments-list.tsx
@@ -8,7 +8,7 @@ export type Comment = Omit<CommentProps, 'canReply' | 'saveComment'> & {
   child?: Comment[];
 };
 
-export type CommentsListProps = {
+export type CommentsListProps = Pick<CommentProps, 'Notice' | 'saveComment'> & {
   /**
    * An array of comments.
    */
@@ -17,10 +17,6 @@ export type CommentsListProps = {
    * The maximum depth. Use `0` to not display nested comments.
    */
   depth: 0 | 1 | 2 | 3 | 4;
-  /**
-   * A callback function to save comment form data.
-   */
-  saveComment: CommentProps['saveComment'];
 };
 
 /**
@@ -31,6 +27,7 @@ export type CommentsListProps = {
 const CommentsList: FC<CommentsListProps> = ({
   comments,
   depth,
+  Notice,
   saveComment,
 }) => {
   /**
@@ -48,8 +45,9 @@ const CommentsList: FC<CommentsListProps> = ({
     return commentsList.map(({ child, ...comment }) => (
       <li key={comment.id} className={styles.item}>
         <SingleComment
-          saveComment={saveComment}
           canReply={!isLastLevel}
+          Notice={Notice}
+          saveComment={saveComment}
           {...comment}
         />
         {child && !isLastLevel && (
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 480c76e..8e518aa 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -85,12 +85,12 @@ export default {
     },
     id: {
       control: {
-        type: 'text',
+        type: 'number',
       },
       description: 'The page id.',
       type: {
-        name: 'string',
-        required: true,
+        name: 'number',
+        required: false,
       },
     },
     intro: {
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index ac021ba..045b8c1 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -1,4 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
+import Notice, { type NoticeKind } from '@components/atoms/layout/notice';
 import Sidebar from '@components/atoms/layout/sidebar';
 import PageFooter, {
   type PageFooterProps,
@@ -9,15 +10,19 @@ import PageHeader, {
 import Breadcrumb, {
   type BreadcrumbItem,
 } from '@components/molecules/nav/breadcrumb';
-import CommentForm from '@components/organisms/forms/comment-form';
+import CommentForm, {
+  type CommentFormProps,
+} from '@components/organisms/forms/comment-form';
 import CommentsList, {
   type CommentsListProps,
 } from '@components/organisms/layout/comments-list';
 import TableOfContents from '@components/organisms/widgets/table-of-contents';
+import { type SendCommentVars } from '@services/graphql/api';
+import { sendComment } from '@services/graphql/comments';
 import useIsMounted from '@utils/hooks/use-is-mounted';
-import { FC, ReactNode, useRef } from 'react';
+import { FC, ReactNode, useRef, useState } from 'react';
 import { useIntl } from 'react-intl';
-import Layout, { LayoutProps } from '../layout/layout';
+import Layout, { type LayoutProps } from '../layout/layout';
 import styles from './page-layout.module.scss';
 
 export type PageLayoutProps = {
@@ -45,6 +50,10 @@ export type PageLayoutProps = {
    * The header metadata.
    */
   headerMeta?: PageHeaderProps['meta'];
+  /**
+   * The page id.
+   */
+  id?: number;
   /**
    * The page introduction.
    */
@@ -79,6 +88,7 @@ const PageLayout: FC<PageLayoutProps> = ({
   comments,
   footerMeta,
   headerMeta,
+  id,
   intro,
   isHome = false,
   widgets,
@@ -106,8 +116,56 @@ const PageLayout: FC<PageLayoutProps> = ({
     ? 'article--has-comments'
     : 'article--no-comments';
 
-  const saveComment = () => {
-    return null;
+  const [status, setStatus] = useState<NoticeKind>('info');
+  const [statusMessage, setStatusMessage] = useState<string>('');
+  const isReplyRef = useRef<boolean>(false);
+
+  const saveComment: CommentFormProps['saveComment'] = async (data, reset) => {
+    if (!id) throw new Error('Page id missing. Cannot save comment.');
+
+    const { comment: commentBody, email, name, parentId, website } = data;
+    const commentData: SendCommentVars = {
+      author: name,
+      authorEmail: email,
+      authorUrl: website || '',
+      clientMutationId: 'contact',
+      commentOn: id,
+      content: commentBody,
+      parent: parentId,
+    };
+    const { comment, success } = await sendComment(commentData);
+
+    isReplyRef.current = !!parentId;
+
+    if (success) {
+      setStatus('success');
+      const successPrefix = intl.formatMessage({
+        defaultMessage: 'Thanks, your comment was successfully sent.',
+        description: 'PageLayout: comment form success message',
+        id: 'B290Ph',
+      });
+      const successMessage = comment?.approved
+        ? intl.formatMessage({
+            defaultMessage: 'It has been approved.',
+            id: 'g3+Ahv',
+            description: 'PageLayout: comment approved.',
+          })
+        : intl.formatMessage({
+            defaultMessage: 'It is now awaiting moderation.',
+            id: 'Vmj5cw',
+            description: 'PageLayout: comment awaiting moderation',
+          });
+      setStatusMessage(`${successPrefix} ${successMessage}`);
+      reset();
+    } else {
+      const error = intl.formatMessage({
+        defaultMessage: 'An error occurred:',
+        description: 'PageLayout: comment form error message',
+        id: 'fkcTGp',
+      });
+      setStatus('error');
+      setStatusMessage(error);
+    }
   };
 
   return (
@@ -154,15 +212,36 @@ const PageLayout: FC<PageLayoutProps> = ({
             <section className={styles.comments__section}>
               <Heading level={2}>{commentsTitle}</Heading>
               <CommentsList
-                saveComment={saveComment}
                 comments={comments}
-                depth={2}
+                depth={1}
+                Notice={
+                  isReplyRef.current === true ? (
+                    <Notice
+                      kind={status}
+                      message={statusMessage}
+                      className={styles.notice}
+                    />
+                  ) : undefined
+                }
+                saveComment={saveComment}
               />
             </section>
           )}
           {allowComments && (
             <section className={styles.comments__section}>
-              <CommentForm saveComment={saveComment} title={commentFormTitle} />
+              <CommentForm
+                saveComment={saveComment}
+                title={commentFormTitle}
+                Notice={
+                  isReplyRef.current === false ? (
+                    <Notice
+                      kind={status}
+                      message={statusMessage}
+                      className={styles.notice}
+                    />
+                  ) : undefined
+                }
+              />
             </section>
           )}
         </div>
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
new file mode 100644
index 0000000..6a47c16
--- /dev/null
+++ b/src/pages/article/[slug].tsx
@@ -0,0 +1,251 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Link from '@components/atoms/links/link';
+import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import Sharing from '@components/organisms/widgets/sharing';
+import PageLayout, {
+  type PageLayoutProps,
+} from '@components/templates/page/page-layout';
+import {
+  getAllArticlesSlugs,
+  getArticleBySlug,
+} from '@services/graphql/articles';
+import { getPostComments } from '@services/graphql/comments';
+import { type Article, type Comment } from '@ts/types/app';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useSettings from '@utils/hooks/use-settings';
+import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import Head from 'next/head';
+import { useRouter } from 'next/router';
+import Script from 'next/script';
+import { ParsedUrlQuery } from 'querystring';
+import { useIntl } from 'react-intl';
+import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
+import useSWR from 'swr';
+
+type ArticlePageProps = {
+  comments: Comment[];
+  post: Article;
+  translation: Messages;
+};
+
+/**
+ * Article page.
+ */
+const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
+  const { content, id, intro, meta, slug, title } = post;
+  const { author, commentsCount, cover, dates, seo, thematics, topics } = meta;
+  const { data } = useSWR(() => id, getPostComments, {
+    fallbackData: comments,
+  });
+  const intl = useIntl();
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const blogLabel = intl.formatMessage({
+    defaultMessage: 'Blog',
+    description: 'Breadcrumb: blog label',
+    id: 'Es52wh',
+  });
+  const breadcrumb: BreadcrumbItem[] = [
+    { id: 'home', name: homeLabel, url: '/' },
+    { id: 'blog', name: blogLabel, url: '/blog' },
+    { id: 'article', name: title, url: `/article/${slug}` },
+  ];
+
+  const headerMeta: PageLayoutProps['headerMeta'] = {
+    author: author?.name,
+    publication: { date: dates.publication },
+    update: dates.update ? { date: dates.update } : undefined,
+    thematics:
+      thematics &&
+      thematics.map((thematic) => (
+        <Link key={thematic.id} href={`/thematique/${thematic.slug}`}>
+          {thematic.name}
+        </Link>
+      )),
+  };
+
+  const footerMeta: PageLayoutProps['footerMeta'] = {
+    topics:
+      topics &&
+      topics.map((topic) => {
+        return (
+          <ButtonLink key={topic.id} target={`/sujet/${topic.slug}`}>
+            {topic.name}
+          </ButtonLink>
+        );
+      }),
+  };
+
+  const { website } = useSettings();
+  const { asPath } = useRouter();
+  const pageUrl = `${website.url}${asPath}`;
+  const pagePublicationDate = new Date(dates.publication);
+  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
+
+  const webpageSchema: WebPage = {
+    '@id': `${pageUrl}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
+    lastReviewed: dates.update,
+    name: seo.title,
+    description: seo.description,
+    reviewedBy: { '@id': `${website.url}/#branding` },
+    url: `${pageUrl}`,
+    isPartOf: {
+      '@id': `${website.url}`,
+    },
+  };
+
+  const blogSchema: Blog = {
+    '@id': `${website.url}/#blog`,
+    '@type': 'Blog',
+    blogPost: { '@id': `${website.url}/#article` },
+    isPartOf: {
+      '@id': `${pageUrl}`,
+    },
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+  };
+
+  const blogPostSchema: BlogPosting = {
+    '@id': `${website.url}/#article`,
+    '@type': 'BlogPosting',
+    name: title,
+    description: intro,
+    articleBody: content,
+    author: { '@id': `${website.url}/#branding` },
+    commentCount: commentsCount,
+    copyrightYear: pagePublicationDate.getFullYear(),
+    creator: { '@id': `${website.url}/#branding` },
+    dateCreated: pagePublicationDate.toISOString(),
+    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
+    datePublished: pagePublicationDate.toISOString(),
+    discussionUrl: `${pageUrl}/#comments`,
+    editor: { '@id': `${website.url}/#branding` },
+    headline: title,
+    image: cover?.src,
+    inLanguage: website.locales.default,
+    isPartOf: {
+      '@id': `${website.url}/blog`,
+    },
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    mainEntityOfPage: { '@id': `${pageUrl}` },
+    thumbnailUrl: cover?.src,
+  };
+
+  const schemaJsonLd: Graph = {
+    '@context': 'https://schema.org',
+    '@graph': [webpageSchema, blogSchema, blogPostSchema],
+  };
+
+  /**
+   * Convert the comments list to the right format.
+   *
+   * @param {Comment[]} list - The comments list.
+   * @returns {PageLayoutProps['comments']} - The formatted comments list.
+   */
+  const getCommentsList = (list: Comment[]): PageLayoutProps['comments'] => {
+    return list.map((comment) => {
+      const {
+        content: commentBody,
+        id: commentId,
+        meta: commentMeta,
+        parentId,
+        replies,
+      } = comment;
+      const { author: commentAuthor, date } = commentMeta;
+      const { name, avatar, website: authorUrl } = commentAuthor;
+
+      return {
+        author: { name, avatar: avatar!.src, url: authorUrl },
+        content: commentBody,
+        id: commentId,
+        publication: date,
+        child: getCommentsList(replies),
+        parentId,
+      };
+    });
+  };
+
+  return (
+    <>
+      <Head>
+        <title>{seo.title}</title>
+        <meta name="description" content={seo.description} />
+        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:type" content="article" />
+        <meta property="og:title" content={title} />
+        <meta property="og:description" content={intro} />
+      </Head>
+      <Script
+        id="schema-project"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+      />
+      <PageLayout
+        allowComments={true}
+        breadcrumb={breadcrumb}
+        comments={data && getCommentsList(data)}
+        footerMeta={footerMeta}
+        headerMeta={headerMeta}
+        id={id as number}
+        intro={intro}
+        title={title}
+        withToC={true}
+        widgets={[
+          <Sharing
+            key="sharing-widget"
+            data={{ excerpt: intro, title, url: pageUrl }}
+            media={[
+              'diaspora',
+              'email',
+              'facebook',
+              'journal-du-hacker',
+              'linkedin',
+              'twitter',
+            ]}
+          />,
+        ]}
+      >
+        {content}
+      </PageLayout>
+    </>
+  );
+};
+
+interface PostParams extends ParsedUrlQuery {
+  slug: string;
+}
+
+export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({
+  locale,
+  params,
+}) => {
+  const post = await getArticleBySlug(params!.slug as PostParams['slug']);
+  const comments = await getPostComments(post.id as number);
+  const translation = await loadTranslation(locale);
+
+  return {
+    props: {
+      comments: JSON.parse(JSON.stringify(comments)),
+      post: JSON.parse(JSON.stringify(post)),
+      translation,
+    },
+  };
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+  const slugs = await getAllArticlesSlugs();
+  const paths = slugs.map((slug) => {
+    return { params: { slug } };
+  });
+
+  return {
+    paths,
+    fallback: true,
+  };
+};
+
+export default ArticlePage;
diff --git a/src/services/graphql/api.ts b/src/services/graphql/api.ts
index 171ab23..9f68ddc 100644
--- a/src/services/graphql/api.ts
+++ b/src/services/graphql/api.ts
@@ -6,6 +6,7 @@ import {
   articlesSlugQuery,
   totalArticlesQuery,
 } from './articles.query';
+import { sendCommentMutation } from './comments.mutation';
 import { commentsQuery } from './comments.query';
 import { sendMailMutation } from './contact.mutation';
 import {
@@ -21,7 +22,7 @@ import {
   totalTopicsQuery,
 } from './topics.query';
 
-export type Mutations = typeof sendMailMutation;
+export type Mutations = typeof sendMailMutation | typeof sendCommentMutation;
 
 export type Queries =
   | typeof articlesQuery
@@ -51,6 +52,10 @@ export type CommentsResponse<T> = {
   comments: T;
 };
 
+export type SendCommentResponse<T> = {
+  createComment: T;
+};
+
 export type SendMailResponse<T> = {
   sendEmail: T;
 };
@@ -101,6 +106,7 @@ export type ResponseMap<T> = {
   [articlesQuery]: ArticlesResponse<EdgesResponse<T>>;
   [articlesSlugQuery]: ArticlesResponse<EdgesResponse<T>>;
   [commentsQuery]: CommentsResponse<NodesResponse<T>>;
+  [sendCommentMutation]: SendCommentResponse<T>;
   [sendMailMutation]: SendMailResponse<T>;
   [thematicBySlugQuery]: ThematicResponse<T>;
   [thematicsListQuery]: ThematicsResponse<EdgesResponse<T>>;
@@ -154,10 +160,53 @@ export type SearchVar = {
   search?: string;
 };
 
+export type SendCommentVars = {
+  /**
+   * The author name.
+   */
+  author: string;
+  /**
+   * The author e-mail address.
+   */
+  authorEmail: string;
+  /**
+   * The author website.
+   */
+  authorUrl: string;
+  /**
+   * A mutation id.
+   */
+  clientMutationId: string;
+  /**
+   * A post or page id.
+   */
+  commentOn: number;
+  /**
+   * The comment body.
+   */
+  content: string;
+  /**
+   * The comment parent.
+   */
+  parent?: number;
+};
+
 export type SendMailVars = {
+  /**
+   * The mail body.
+   */
   body: string;
+  /**
+   * A mutation id.
+   */
   clientMutationId: string;
+  /**
+   * The reply to e-mail address.
+   */
   replyTo: string;
+  /**
+   * The mail subject.
+   */
   subject: string;
 };
 
@@ -167,6 +216,7 @@ export type VariablesMap = {
   [articlesQuery]: EdgesVars;
   [articlesSlugQuery]: EdgesVars;
   [commentsQuery]: ByContentIdVar;
+  [sendCommentMutation]: SendCommentVars;
   [sendMailMutation]: SendMailVars;
   [thematicBySlugQuery]: BySlugVar;
   [thematicsListQuery]: EdgesVars;
diff --git a/src/services/graphql/comments.mutation.ts b/src/services/graphql/comments.mutation.ts
new file mode 100644
index 0000000..f52c7e9
--- /dev/null
+++ b/src/services/graphql/comments.mutation.ts
@@ -0,0 +1,30 @@
+/**
+ * Send comment mutation.
+ */
+export const sendCommentMutation = `mutation CreateComment(
+  $author: String!
+  $authorEmail: String!
+  $authorUrl: String!
+  $content: String!
+  $parent: ID = null
+  $commentOn: Int!
+  $clientMutationId: String!
+) {
+  createComment(
+    input: {
+      author: $author
+      authorEmail: $authorEmail
+      authorUrl: $authorUrl
+      content: $content
+      parent: $parent
+      commentOn: $commentOn
+      clientMutationId: $clientMutationId
+    }
+  ) {
+    clientMutationId
+    success
+    comment {
+      approved
+    }
+  }
+}`;
diff --git a/src/services/graphql/comments.ts b/src/services/graphql/comments.ts
new file mode 100644
index 0000000..28ddfd0
--- /dev/null
+++ b/src/services/graphql/comments.ts
@@ -0,0 +1,102 @@
+import { Comment } from '@ts/types/app';
+import { RawComment } from '@ts/types/raw-data';
+import { getAuthorFromRawData } from '@utils/helpers/author';
+import { fetchAPI, getAPIUrl, SendCommentVars } from './api';
+import { sendCommentMutation } from './comments.mutation';
+import { commentsQuery } from './comments.query';
+
+/**
+ * Create a comments tree with replies.
+ *
+ * @param {Comment[]} comments - A flatten comments list.
+ * @returns {Comment[]} An array of comments with replies.
+ */
+export const buildCommentsTree = (comments: Comment[]): Comment[] => {
+  type CommentsHashTable = {
+    [key: string]: Comment;
+  };
+
+  const hashTable: CommentsHashTable = Object.create(null);
+  const commentsTree: Comment[] = [];
+
+  comments.forEach(
+    (comment) => (hashTable[comment.id] = { ...comment, replies: [] })
+  );
+
+  comments.forEach((comment) => {
+    if (!comment.parentId) {
+      commentsTree.push(hashTable[comment.id]);
+    } else {
+      hashTable[comment.parentId].replies.push(hashTable[comment.id]);
+    }
+  });
+
+  return commentsTree;
+};
+
+/**
+ * Convert a comment from RawComment to Comment type.
+ *
+ * @param {RawComment} comment - A raw comment.
+ * @returns {Comment} A formatted comment.
+ */
+export const getCommentFromRawData = (comment: RawComment): Comment => {
+  const { author, databaseId, date, parentDatabaseId, ...data } = comment;
+
+  return {
+    id: databaseId,
+    meta: {
+      author: getAuthorFromRawData(author.node, 'comment'),
+      date,
+    },
+    parentId: parentDatabaseId,
+    replies: [],
+    ...data,
+  };
+};
+
+/**
+ * Retrieve a comments list by post id.
+ *
+ * @param {number} id - A post id.
+ * @returns {Promise<Comment[]>} The comments list.
+ */
+export const getPostComments = async (id: number): Promise<Comment[]> => {
+  const response = await fetchAPI<RawComment, typeof commentsQuery>({
+    api: getAPIUrl(),
+    query: commentsQuery,
+    variables: { contentId: id },
+  });
+
+  const comments = response.comments.nodes.map((comment) =>
+    getCommentFromRawData(comment)
+  );
+
+  return buildCommentsTree(comments);
+};
+
+export type SentComment = {
+  clientMutationId: string;
+  success: boolean;
+  comment: {
+    approved: boolean;
+  } | null;
+};
+
+/**
+ * Send a comment using GraphQL API.
+ *
+ * @param {SendCommentVars} data - The comment data.
+ * @returns {Promise<SentEmail>} The mutation response.
+ */
+export const sendComment = async (
+  data: SendCommentVars
+): Promise<SentComment> => {
+  const response = await fetchAPI<SentComment, typeof sendCommentMutation>({
+    api: getAPIUrl(),
+    query: sendCommentMutation,
+    variables: { ...data },
+  });
+
+  return response.createComment;
+};
-- 
cgit v1.2.3


From 2155550fa36a3bc3c8f66e0926530123b4018cd4 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 16 May 2022 12:46:38 +0200
Subject: refactor: use custom hook for breadcrumb items and schema

---
 src/components/templates/layout/layout.stories.tsx |  54 ++++++++---
 src/components/templates/layout/layout.test.tsx    |  12 ++-
 src/components/templates/layout/layout.tsx         |  26 ++++-
 .../templates/page/page-layout.stories.tsx         |  12 +++
 src/components/templates/page/page-layout.test.tsx |  41 ++++++--
 src/components/templates/page/page-layout.tsx      |  11 ++-
 .../sectioned/sectioned-layout.stories.tsx         |  24 +++--
 .../templates/sectioned/sectioned-layout.test.tsx  |   9 +-
 .../templates/sectioned/sectioned-layout.tsx       |   8 +-
 src/pages/404.tsx                                  |  16 ++--
 src/pages/article/[slug].tsx                       |  24 ++---
 src/pages/blog/index.tsx                           |  16 ++--
 src/pages/contact.tsx                              |  22 ++---
 src/pages/cv.tsx                                   |  22 ++---
 src/pages/index.tsx                                |   9 +-
 src/pages/mentions-legales.tsx                     |  22 ++---
 src/pages/projets/[slug].tsx                       |  27 ++----
 src/pages/projets/index.tsx                        |  22 ++---
 src/pages/recherche/index.tsx                      |  22 ++---
 src/pages/sujet/[slug].tsx                         |  22 ++---
 src/pages/thematique/[slug].tsx                    |  22 ++---
 src/utils/hooks/use-breadcrumb.tsx                 | 106 +++++++++++++++++++++
 22 files changed, 343 insertions(+), 206 deletions(-)
 create mode 100644 src/utils/hooks/use-breadcrumb.tsx

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/components/templates/layout/layout.stories.tsx b/src/components/templates/layout/layout.stories.tsx
index 2415412..105e808 100644
--- a/src/components/templates/layout/layout.stories.tsx
+++ b/src/components/templates/layout/layout.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import LayoutComponent from './layout';
 
 /**
@@ -8,6 +7,10 @@ import LayoutComponent from './layout';
 export default {
   title: 'Templates/LayoutBase',
   component: LayoutComponent,
+  args: {
+    breadcrumbSchema: [],
+    isHome: false,
+  },
   argTypes: {
     children: {
       control: {
@@ -19,6 +22,31 @@ export default {
         required: true,
       },
     },
+    breadcrumbSchema: {
+      control: {
+        type: 'null',
+      },
+      description: 'The JSON schema for breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
+    isHome: {
+      control: {
+        type: 'boolean',
+      },
+      description: 'Determine if it is the homepage.',
+      table: {
+        category: 'Options',
+        defaultValue: { summary: false },
+      },
+      type: {
+        name: 'boolean',
+        required: false,
+      },
+    },
     className: {
       control: {
         type: 'text',
@@ -35,19 +63,17 @@ export default {
   },
   decorators: [
     (Story) => (
-      <IntlProvider locale="en">
-        <div
-          id="__next"
-          style={{
-            flex: 1,
-            display: 'flex',
-            flexFlow: 'column nowrap',
-            minHeight: '100vh',
-          }}
-        >
-          <Story />
-        </div>
-      </IntlProvider>
+      <div
+        id="__next"
+        style={{
+          flex: 1,
+          display: 'flex',
+          flexFlow: 'column nowrap',
+          minHeight: '100vh',
+        }}
+      >
+        <Story />
+      </div>
     ),
   ],
   parameters: {
diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx
index 914e1cd..94145ec 100644
--- a/src/components/templates/layout/layout.test.tsx
+++ b/src/components/templates/layout/layout.test.tsx
@@ -1,34 +1,36 @@
 import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
 import Layout from './layout';
 
 const body =
   'Sit dolorem eveniet. Sit sit odio nemo vitae corrupti modi sint est rerum. Pariatur quidem maiores distinctio. Quia et illum aspernatur est cum.';
+const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 
 describe('Layout', () => {
   it('renders the website header', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('banner')).toBeInTheDocument();
   });
 
   it('renders the website main content', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('main')).toBeInTheDocument();
   });
 
   it('renders the website footer', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('contentinfo')).toBeInTheDocument();
   });
 
   it('renders a skip to content link', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(
       screen.getByRole('link', { name: 'Skip to content' })
     ).toBeInTheDocument();
   });
 
   it('renders an article', () => {
-    render(<Layout>{body}</Layout>);
+    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
     expect(screen.getByRole('article')).toHaveTextContent(body);
   });
 });
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index bfb918b..9e9282b 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -13,7 +13,13 @@ import useSettings from '@utils/hooks/use-settings';
 import Script from 'next/script';
 import { FC, ReactNode } from 'react';
 import { useIntl } from 'react-intl';
-import { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
+import {
+  BreadcrumbList,
+  Person,
+  SearchAction,
+  WebSite,
+  WithContext,
+} from 'schema-dts';
 import styles from './layout.module.scss';
 
 export type QueryAction = SearchAction & {
@@ -21,6 +27,10 @@ export type QueryAction = SearchAction & {
 };
 
 export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
+  /**
+   * The breadcrumb JSON schema.
+   */
+  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * The layout main content.
    */
@@ -36,7 +46,12 @@ export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
  *
  * Render the base layout used by all pages.
  */
-const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
+const Layout: FC<LayoutProps> = ({
+  breadcrumbSchema,
+  children,
+  isHome,
+  ...props
+}) => {
   const intl = useIntl();
   const { website } = useSettings();
   const { baseline, copyright, locales, name, picture, url } = website;
@@ -153,12 +168,17 @@ const Layout: FC<LayoutProps> = ({ children, isHome, ...props }) => {
         id="schema-layout"
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
-      ></Script>
+      />
       <Script
         id="schema-branding"
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
       />
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
       <noscript>
         <div className={styles['noscript-spacing']}></div>
       </noscript>
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 8e518aa..002b951 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -16,6 +16,7 @@ export default {
   component: PageLayoutComponent,
   args: {
     allowComments: false,
+    breadcrumbSchema: [],
   },
   argTypes: {
     allowComments: {
@@ -40,6 +41,17 @@ export default {
         value: {},
       },
     },
+    breadcrumbSchema: {
+      control: {
+        type: null,
+      },
+      description: 'The JSON schema for breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
     children: {
       control: {
         type: 'text',
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
index b8fff6a..b3aea8b 100644
--- a/src/components/templates/page/page-layout.test.tsx
+++ b/src/components/templates/page/page-layout.test.tsx
@@ -1,4 +1,5 @@
 import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
 import PageLayout from './page-layout';
 
 const title = 'Incidunt ad earum';
@@ -6,13 +7,18 @@ const breadcrumb = [
   { id: 'home', url: '#', name: 'Home' },
   { id: 'page', url: '#', name: title },
 ];
+const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 const children =
   'Reprehenderit aut quis aperiam magnam quia id. Vero enim animi placeat quia. Laborum sit odio minima. Dolores et debitis eaque iste quidem. Omnis aliquam illum porro ea non. Quaerat totam iste quos ex facilis officia accusantium.';
 
 describe('PageLayout', () => {
   it('renders the page title', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+      >
         {children}
       </PageLayout>
     );
@@ -23,7 +29,11 @@ describe('PageLayout', () => {
 
   it('renders the page content', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+      >
         {children}
       </PageLayout>
     );
@@ -32,7 +42,11 @@ describe('PageLayout', () => {
 
   it('renders the breadcrumb', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+      >
         {children}
       </PageLayout>
     );
@@ -43,7 +57,12 @@ describe('PageLayout', () => {
 
   it('renders the table of contents', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title} withToC={true}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+        withToC={true}
+      >
         {children}
       </PageLayout>
     );
@@ -54,7 +73,12 @@ describe('PageLayout', () => {
 
   it('renders the comment form', () => {
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title} allowComments={true}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+        allowComments={true}
+      >
         {children}
       </PageLayout>
     );
@@ -79,7 +103,12 @@ describe('PageLayout', () => {
       },
     ];
     render(
-      <PageLayout breadcrumb={breadcrumb} title={title} comments={comments}>
+      <PageLayout
+        breadcrumb={breadcrumb}
+        breadcrumbSchema={breadcrumbSchema}
+        title={title}
+        comments={comments}
+      >
         {children}
       </PageLayout>
     );
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 4e4ff00..bc90f4c 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -25,7 +25,10 @@ import { useIntl } from 'react-intl';
 import Layout, { type LayoutProps } from '../layout/layout';
 import styles from './page-layout.module.scss';
 
-export type PageLayoutProps = {
+export type PageLayoutProps = Pick<
+  LayoutProps,
+  'breadcrumbSchema' | 'isHome'
+> & {
   /**
    * True if the page accepts new comments. Default: false.
    */
@@ -58,10 +61,6 @@ export type PageLayoutProps = {
    * The page introduction.
    */
   intro?: PageHeaderProps['intro'];
-  /**
-   * True if it is homepage. Default: false.
-   */
-  isHome?: LayoutProps['isHome'];
   /**
    * The page title.
    */
@@ -85,6 +84,7 @@ const PageLayout: FC<PageLayoutProps> = ({
   children,
   allowComments = false,
   breadcrumb,
+  breadcrumbSchema,
   comments,
   footerMeta,
   headerMeta,
@@ -170,6 +170,7 @@ const PageLayout: FC<PageLayoutProps> = ({
 
   return (
     <Layout
+      breadcrumbSchema={breadcrumbSchema}
       isHome={isHome}
       className={`${styles.article} ${styles[articleModifier]}`}
     >
diff --git a/src/components/templates/sectioned/sectioned-layout.stories.tsx b/src/components/templates/sectioned/sectioned-layout.stories.tsx
index 9ff3b75..ce31a83 100644
--- a/src/components/templates/sectioned/sectioned-layout.stories.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SectionedLayoutComponent from './sectioned-layout';
 
 /**
@@ -8,7 +7,21 @@ import SectionedLayoutComponent from './sectioned-layout';
 export default {
   title: 'Templates/Sectioned',
   component: SectionedLayoutComponent,
+  args: {
+    breadcrumbSchema: [],
+  },
   argTypes: {
+    breadcrumbSchema: {
+      control: {
+        type: null,
+      },
+      description: 'The JSON schema for breadcrumb items.',
+      type: {
+        name: 'object',
+        required: true,
+        value: {},
+      },
+    },
     sections: {
       description: 'The different sections.',
       type: {
@@ -18,15 +31,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <div id="__next">
-          <Story />
-        </div>
-      </IntlProvider>
-    ),
-  ],
   parameters: {
     layout: 'fullscreen',
   },
diff --git a/src/components/templates/sectioned/sectioned-layout.test.tsx b/src/components/templates/sectioned/sectioned-layout.test.tsx
index 334d1cc..9b8bab5 100644
--- a/src/components/templates/sectioned/sectioned-layout.test.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.test.tsx
@@ -1,6 +1,8 @@
 import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
 import SectionedLayout from './sectioned-layout';
 
+const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 const sections = [
   {
     title: 'Section 1',
@@ -26,7 +28,12 @@ const sections = [
 
 describe('SectionedLayout', () => {
   it('renders the correct number of section', () => {
-    render(<SectionedLayout sections={sections} />);
+    render(
+      <SectionedLayout
+        breadcrumbSchema={breadcrumbSchema}
+        sections={sections}
+      />
+    );
     expect(screen.getAllByRole('heading', { name: /^Section/ })).toHaveLength(
       sections.length
     );
diff --git a/src/components/templates/sectioned/sectioned-layout.tsx b/src/components/templates/sectioned/sectioned-layout.tsx
index 36ca039..58d5ad0 100644
--- a/src/components/templates/sectioned/sectioned-layout.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.tsx
@@ -3,11 +3,11 @@ import Section, {
   type SectionVariant,
 } from '@components/atoms/layout/section';
 import { FC } from 'react';
-import Layout from '../layout/layout';
+import Layout, { type LayoutProps } from '../layout/layout';
 
 export type Section = Pick<SectionProps, 'content' | 'title'>;
 
-export type SectionedLayoutProps = {
+export type SectionedLayoutProps = Pick<LayoutProps, 'breadcrumbSchema'> & {
   /**
    * An array of objects describing each section.
    */
@@ -19,7 +19,7 @@ export type SectionedLayoutProps = {
  *
  * Render a sectioned layout.
  */
-const SectionedLayout: FC<SectionedLayoutProps> = ({ sections }) => {
+const SectionedLayout: FC<SectionedLayoutProps> = ({ sections, ...props }) => {
   const getSections = (items: SectionProps[]) => {
     return items.map((section, index) => {
       const variant: SectionVariant = index % 2 ? 'light' : 'dark';
@@ -37,7 +37,7 @@ const SectionedLayout: FC<SectionedLayoutProps> = ({ sections }) => {
     });
   };
 
-  return <Layout>{getSections(sections)}</Layout>;
+  return <Layout {...props}>{getSections(sections)}</Layout>;
 };
 
 export default SectionedLayout;
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 7459c80..4f6e22d 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -1,5 +1,4 @@
 import Link from '@components/atoms/links/link';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout from '@components/templates/page/page-layout';
 import {
@@ -16,6 +15,7 @@ import {
   getLinksListItems,
   getPageLinkFromRawData,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -53,15 +53,10 @@ const Error404Page: NextPage<Error404PageProps> = ({
       link: (chunks: ReactNode) => <Link href="/contact">{chunks}</Link>,
     }
   );
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/404`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'error-404', name: title, url: '/404' },
-  ];
   const pageTitle = intl.formatMessage(
     {
       defaultMessage: 'Error 404: Page not found - {websiteName}',
@@ -95,7 +90,8 @@ const Error404Page: NextPage<Error404PageProps> = ({
       </Head>
       <PageLayout
         title={title}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         widgets={[
           <LinksListWidget
             key="thematics-list"
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 6a47c16..5eeabd9 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -1,6 +1,5 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Link from '@components/atoms/links/link';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import Sharing from '@components/organisms/widgets/sharing';
 import PageLayout, {
   type PageLayoutProps,
@@ -12,13 +11,13 @@ import {
 import { getPostComments } from '@services/graphql/comments';
 import { type Article, type Comment } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
-import { useIntl } from 'react-intl';
 import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
 import useSWR from 'swr';
 
@@ -37,22 +36,10 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
   const { data } = useSWR(() => id, getPostComments, {
     fallbackData: comments,
   });
-  const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/article/${slug}`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'article', name: title, url: `/article/${slug}` },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     author: author?.name,
@@ -186,7 +173,8 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
       />
       <PageLayout
         allowComments={true}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         comments={data && getCommentsList(data)}
         footerMeta={footerMeta}
         headerMeta={headerMeta}
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 38fabd5..a5ef045 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,5 +1,4 @@
 import Notice from '@components/atoms/layout/notice';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout from '@components/templates/page/page-layout';
@@ -26,6 +25,7 @@ import {
   getLinksListItems,
   getPageLinkFromRawData,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps, NextPage } from 'next';
@@ -58,15 +58,10 @@ const BlogPage: NextPage<BlogPageProps> = ({
     description: 'BlogPage: page title',
     id: '7TbbIk',
   });
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: '/blog',
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: title, url: '/blog' },
-  ];
 
   const { blog, website } = useSettings();
   const { asPath } = useRouter();
@@ -234,7 +229,8 @@ const BlogPage: NextPage<BlogPageProps> = ({
       />
       <PageLayout
         title={title}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         headerMeta={{ total: postsCount }}
         widgets={[
           <LinksListWidget
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index fcfbe1d..617117b 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -1,7 +1,6 @@
-import Notice, { NoticeKind } from '@components/atoms/layout/notice';
-import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import Notice, { type NoticeKind } from '@components/atoms/layout/notice';
 import ContactForm, {
-  ContactFormProps,
+  type ContactFormProps,
 } from '@components/organisms/forms/contact-form';
 import SocialMedia from '@components/organisms/widgets/social-media';
 import PageLayout from '@components/templates/page/page-layout';
@@ -17,19 +16,15 @@ import Script from 'next/script';
 import { useState } from 'react';
 import { useIntl } from 'react-intl';
 import { ContactPage as ContactPageSchema, Graph, WebPage } from 'schema-dts';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 
 const ContactPage: NextPage = () => {
   const { dates, intro, seo, title } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/contact`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'contact', name: title, url: '/contact' },
-  ];
 
   const socialMediaTitle = intl.formatMessage({
     defaultMessage: 'Find me elsewhere',
@@ -148,9 +143,10 @@ const ContactPage: NextPage = () => {
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
       <PageLayout
-        title="Contact"
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         intro={intro}
-        breadcrumb={breadcrumb}
+        title="Contact"
         widgets={widgets}
       >
         <ContactForm
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index 0e4765e..b3dec10 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -1,5 +1,4 @@
 import Link from '@components/atoms/links/link';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import ImageWidget from '@components/organisms/widgets/image-widget';
 import SocialMedia from '@components/organisms/widgets/social-media';
 import PageLayout, {
@@ -8,6 +7,7 @@ import PageLayout, {
 import CVContent, { data, meta } from '@content/pages/cv.mdx';
 import styles from '@styles/pages/cv.module.scss';
 import { loadTranslation } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -24,15 +24,10 @@ const CVPage: NextPage = () => {
   const intl = useIntl();
   const { file, image } = data;
   const { dates, intro, seo, title } = meta;
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/cv`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'cv', name: title, url: '/cv' },
-  ];
 
   const imageWidgetTitle = intl.formatMessage({
     defaultMessage: 'Others formats',
@@ -148,12 +143,13 @@ const CVPage: NextPage = () => {
 
   return (
     <PageLayout
-      title={title}
-      intro={intro}
+      breadcrumb={breadcrumbItems}
+      breadcrumbSchema={breadcrumbSchema}
       headerMeta={headerMeta}
-      breadcrumb={breadcrumb}
-      withToC={true}
+      intro={intro}
+      title={title}
       widgets={widgets}
+      withToC={true}
     >
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index bc9b572..1143a33 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -19,6 +19,7 @@ import { getArticlesCard } from '@services/graphql/articles';
 import styles from '@styles/pages/home.module.scss';
 import { ArticleCard } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
 import { GetStaticProps, NextPage } from 'next';
@@ -38,6 +39,10 @@ type HomeProps = {
  */
 const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   const intl = useIntl();
+  const { schema: breadcrumbSchema } = useBreadcrumb({
+    title: '',
+    url: `/`,
+  });
 
   /**
    * Retrieve a list of coding links.
@@ -322,7 +327,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   };
 
   return (
-    <Layout isHome={true}>
+    <Layout breadcrumbSchema={breadcrumbSchema} isHome={true}>
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
@@ -340,7 +345,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   );
 };
 
-export const getStaticProps: GetStaticProps = async ({ locale }) => {
+export const getStaticProps: GetStaticProps<HomeProps> = async ({ locale }) => {
   const translation = await loadTranslation(locale);
   const recentPosts = await getArticlesCard({ first: 3 });
 
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index 9235e69..41bc218 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -1,35 +1,28 @@
 import Link from '@components/atoms/links/link';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import LegalNoticeContent, { meta } from '@content/pages/legal-notice.mdx';
 import { loadTranslation } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
-import { useIntl } from 'react-intl';
 import { Article, Graph, WebPage } from 'schema-dts';
 
 /**
  * Legal Notice page.
  */
 const LegalNoticePage: NextPage = () => {
-  const intl = useIntl();
   const { dates, intro, seo, title } = meta;
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/mentions-legales`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'legal-notice', name: title, url: '/mentions-legales' },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: {
@@ -93,10 +86,11 @@ const LegalNoticePage: NextPage = () => {
 
   return (
     <PageLayout
-      title={title}
-      intro={intro}
+      breadcrumb={breadcrumbItems}
+      breadcrumbSchema={breadcrumbSchema}
       headerMeta={headerMeta}
-      breadcrumb={breadcrumb}
+      intro={intro}
+      title={title}
       withToC={true}
     >
       <Head>
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 711a5cd..1a90e0f 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -3,7 +3,6 @@ import SocialLink, { SocialWebsite } from '@components/atoms/links/social-link';
 import Spinner from '@components/atoms/loaders/spinner';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Code from '@components/molecules/layout/code';
-import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import Gallery from '@components/organisms/images/gallery';
 import Overview, { OverviewMeta } from '@components/organisms/layout/overview';
 import Sharing from '@components/organisms/widgets/sharing';
@@ -14,6 +13,7 @@ import { ProjectPreview, Repos } from '@ts/types/app';
 import { loadTranslation, Messages } from '@utils/helpers/i18n';
 import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
 import { capitalize } from '@utils/helpers/strings';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useGithubApi, { RepoData } from '@utils/hooks/use-github-api';
 import useSettings from '@utils/hooks/use-settings';
 import { MDXComponents, NestedMDXComponents } from 'mdx/types';
@@ -37,21 +37,10 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
   const { id, intro, meta, title } = project;
   const { cover, dates, license, repos, seo, technologies } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/projets/${id}`,
   });
-  const projectsLabel = intl.formatMessage({
-    defaultMessage: 'Projects',
-    description: 'Breadcrumb: projects label',
-    id: '28GZdv',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'projects', name: projectsLabel, url: '/projets' },
-    { id: 'project', name: title, url: `/projets/${id}` },
-  ];
 
   const ProjectContent: ComponentType<MDXComponents> =
     require(`../../content/projects/${id}.mdx`).default;
@@ -192,7 +181,8 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
       <PageLayout
         title={title}
         intro={intro}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         headerMeta={headerMeta}
         withToC={true}
         widgets={[
@@ -217,7 +207,10 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
   );
 };
 
-export const getStaticProps: GetStaticProps = async ({ locale, params }) => {
+export const getStaticProps: GetStaticProps<ProjectPageProps> = async ({
+  locale,
+  params,
+}) => {
   const translation = await loadTranslation(locale);
   const { slug } = params!;
   const project = await getProjectData(slug as string);
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index a6858c8..4a58269 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -1,5 +1,4 @@
 import Link from '@components/atoms/links/link';
-import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import CardsList, {
   CardsListItem,
 } from '@components/organisms/layout/cards-list';
@@ -9,13 +8,13 @@ import styles from '@styles/pages/projects.module.scss';
 import { ProjectCard } from '@ts/types/app';
 import { loadTranslation, Messages } from '@utils/helpers/i18n';
 import { getProjectsCard } from '@utils/helpers/projects';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
-import { useIntl } from 'react-intl';
 import { Article, Graph, WebPage } from 'schema-dts';
 
 type ProjectsPageProps = {
@@ -27,17 +26,11 @@ type ProjectsPageProps = {
  * Projects page.
  */
 const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
-  const intl = useIntl();
   const { dates, seo, title } = meta;
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/projets`,
   });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'projects', name: title, url: '/projets' },
-  ];
 
   const items: CardsListItem[] = projects.map(
     ({ id, meta: projectMeta, slug, title: projectTitle }) => {
@@ -120,7 +113,8 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
       <PageLayout
         title={title}
         intro={<PageContent components={components} />}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
       >
         <CardsList items={items} titleLevel={2} className={styles.list} />
       </PageLayout>
@@ -128,7 +122,9 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
   );
 };
 
-export const getStaticProps: GetStaticProps = async ({ locale }) => {
+export const getStaticProps: GetStaticProps<ProjectsPageProps> = async ({
+  locale,
+}) => {
   const projects = await getProjectsCard();
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index bf14861..0a7dc60 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -1,6 +1,5 @@
 import Notice from '@components/atoms/layout/notice';
 import Spinner from '@components/atoms/loaders/spinner';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout from '@components/templates/page/page-layout';
@@ -26,6 +25,7 @@ import {
   getLinksListItems,
   getPageLinkFromRawData,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
@@ -65,21 +65,10 @@ const SearchPage: NextPage<SearchPageProps> = ({
         description: 'SearchPage: SEO - Page title',
         id: 'WDwNDl',
       });
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/recherche`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'search', name: title, url: '/recherche' },
-  ];
 
   const { blog, website } = useSettings();
   const pageTitle = `${title} - ${website.name}`;
@@ -253,7 +242,8 @@ const SearchPage: NextPage<SearchPageProps> = ({
       />
       <PageLayout
         title={title}
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         headerMeta={{ total: postsCount }}
         widgets={[
           <LinksListWidget
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 22fb531..447d969 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -1,5 +1,4 @@
 import Heading from '@components/atoms/headings/heading';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout, {
@@ -18,6 +17,7 @@ import {
   getPageLinkFromRawData,
   getPostMeta,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -37,21 +37,10 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   const { content, intro, meta, slug, title } = currentTopic;
   const { articles, dates, seo, thematics } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/sujet/${slug}`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'topic', name: title, url: `/sujet/${slug}` },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
@@ -149,7 +138,8 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
       <PageLayout
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         title={title}
         intro={intro}
         headerMeta={headerMeta}
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index dd7e80d..13ef0da 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -1,5 +1,4 @@
 import Heading from '@components/atoms/headings/heading';
-import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout, {
@@ -18,6 +17,7 @@ import {
   getPageLinkFromRawData,
   getPostMeta,
 } from '@utils/helpers/pages';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -40,21 +40,10 @@ const ThematicPage: NextPage<ThematicPageProps> = ({
   const { content, intro, meta, slug, title } = currentThematic;
   const { articles, dates, seo, topics } = meta;
   const intl = useIntl();
-  const homeLabel = intl.formatMessage({
-    defaultMessage: 'Home',
-    description: 'Breadcrumb: home label',
-    id: 'j5k9Fe',
+  const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
+    title,
+    url: `/thematique/${slug}`,
   });
-  const blogLabel = intl.formatMessage({
-    defaultMessage: 'Blog',
-    description: 'Breadcrumb: blog label',
-    id: 'Es52wh',
-  });
-  const breadcrumb: BreadcrumbItem[] = [
-    { id: 'home', name: homeLabel, url: '/' },
-    { id: 'blog', name: blogLabel, url: '/blog' },
-    { id: 'thematic', name: title, url: `/thematique/${slug}` },
-  ];
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
@@ -152,7 +141,8 @@ const ThematicPage: NextPage<ThematicPageProps> = ({
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
       <PageLayout
-        breadcrumb={breadcrumb}
+        breadcrumb={breadcrumbItems}
+        breadcrumbSchema={breadcrumbSchema}
         title={title}
         intro={intro}
         headerMeta={headerMeta}
diff --git a/src/utils/hooks/use-breadcrumb.tsx b/src/utils/hooks/use-breadcrumb.tsx
new file mode 100644
index 0000000..087d400
--- /dev/null
+++ b/src/utils/hooks/use-breadcrumb.tsx
@@ -0,0 +1,106 @@
+import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import { slugify } from '@utils/helpers/strings';
+import { useIntl } from 'react-intl';
+import { BreadcrumbList } from 'schema-dts';
+import useSettings from './use-settings';
+
+export type useBreadcrumbProps = {
+  /**
+   * The current page title.
+   */
+  title: string;
+  /**
+   * The current page url.
+   */
+  url: string;
+};
+
+export type useBreadcrumbReturn = {
+  /**
+   * The breadcrumb items.
+   */
+  items: BreadcrumbItem[];
+  /**
+   * The breadcrumb JSON schema.
+   */
+  schema: BreadcrumbList['itemListElement'][];
+};
+
+/**
+ * Retrieve the breadcrumb items.
+ *
+ * @param {useBreadcrumbProps} props - An object (the current page title & url).
+ * @returns {useBreadcrumbReturn} The breadcrumb items and its JSON schema.
+ */
+const useBreadcrumb = ({
+  title,
+  url,
+}: useBreadcrumbProps): useBreadcrumbReturn => {
+  const intl = useIntl();
+  const { website } = useSettings();
+  const isArticle = url.startsWith('/article/');
+  const isHome = url === '/';
+  const isProject = url.startsWith('/projets/');
+  const isSearch = url.startsWith('/recherche');
+  const isThematic = url.startsWith('/thematique/');
+  const isTopic = url.startsWith('/sujet/');
+
+  const homeLabel = intl.formatMessage({
+    defaultMessage: 'Home',
+    description: 'Breadcrumb: home label',
+    id: 'j5k9Fe',
+  });
+  const items: BreadcrumbItem[] = [{ id: 'home', name: homeLabel, url: '/' }];
+  const schema: BreadcrumbList['itemListElement'][] = [
+    {
+      '@type': 'ListItem',
+      position: 1,
+      name: homeLabel,
+      item: website.url,
+    },
+  ];
+
+  if (isHome) return { items, schema };
+
+  if (isArticle || isSearch || isThematic || isTopic) {
+    const blogLabel = intl.formatMessage({
+      defaultMessage: 'Blog',
+      description: 'Breadcrumb: blog label',
+      id: 'Es52wh',
+    });
+    items.push({ id: 'blog', name: blogLabel, url: '/blog' });
+    schema.push({
+      '@type': 'ListItem',
+      position: 2,
+      name: blogLabel,
+      item: `${website.url}/blog`,
+    });
+  }
+
+  if (isProject) {
+    const projectsLabel = intl.formatMessage({
+      defaultMessage: 'Projects',
+      description: 'Breadcrumb: projects label',
+      id: '28GZdv',
+    });
+    items.push({ id: 'blog', name: projectsLabel, url: '/projets' });
+    schema.push({
+      '@type': 'ListItem',
+      position: 2,
+      name: projectsLabel,
+      item: `${website.url}/projets`,
+    });
+  }
+
+  items.push({ id: slugify(title), name: title, url });
+  schema.push({
+    '@type': 'ListItem',
+    position: schema.length + 1,
+    name: title,
+    item: `${website.url}${url}`,
+  });
+
+  return { items, schema };
+};
+
+export default useBreadcrumb;
-- 
cgit v1.2.3


From c77c58e18143233be042c4980a6ed08ae9beac52 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 16 May 2022 19:40:23 +0200
Subject: chore: adjust and complete missing styles

* add logo to topics pages and links
* add Prism styles to articles
* and a few other adjustements
---
 src/components/atoms/forms/form.tsx                |   3 +-
 src/components/atoms/links/link.module.scss        |  23 --
 .../atoms/lists/description-list-item.module.scss  |   2 +
 src/components/molecules/layout/meta.tsx           |  59 ++--
 src/components/molecules/layout/page-footer.tsx    |   4 +-
 .../molecules/layout/page-header.module.scss       |   6 +
 src/components/molecules/layout/page-header.tsx    |  11 +-
 src/components/organisms/forms/comment-form.tsx    |  12 +-
 .../organisms/widgets/sharing.stories.tsx          |  21 +-
 src/components/organisms/widgets/sharing.tsx       |   7 +-
 .../templates/page/page-layout.module.scss         |   6 +
 .../templates/page/page-layout.stories.tsx         |   2 +-
 src/components/templates/page/page-layout.tsx      |  17 +-
 src/pages/article/[slug].tsx                       |  43 ++-
 src/pages/blog/index.tsx                           |  12 +-
 src/pages/cv.tsx                                   |  19 +-
 src/pages/recherche/index.tsx                      |  12 +-
 src/pages/sujet/[slug].tsx                         |  35 ++-
 src/services/graphql/articles.query.ts             |  11 +
 src/services/graphql/topics.query.ts               |  11 +
 src/styles/base/_typography.scss                   |  24 +-
 src/styles/components/_wp-blocks.scss              | 166 ------------
 src/styles/globals.scss                            |  10 +-
 src/styles/pages/article.module.scss               |  36 +++
 src/styles/pages/partials/_article-headings.scss   |  57 ++++
 src/styles/pages/partials/_article-links.scss      | 104 +++++++
 src/styles/pages/partials/_article-lists.scss      |  65 +++++
 src/styles/pages/partials/_article-media.scss      |  11 +
 src/styles/pages/partials/_article-prism.scss      | 301 +++++++++++++++++++++
 src/styles/pages/partials/_article-wp-blocks.scss  | 168 ++++++++++++
 src/styles/pages/topic.module.scss                 |   6 +
 src/ts/types/app.ts                                |   1 +
 src/ts/types/raw-data.ts                           |   7 +-
 src/utils/helpers/pages.ts                         |   5 +-
 src/utils/hooks/use-add-prism-class-attr.tsx       |  60 ++++
 35 files changed, 1036 insertions(+), 301 deletions(-)
 delete mode 100644 src/styles/components/_wp-blocks.scss
 create mode 100644 src/styles/pages/article.module.scss
 create mode 100644 src/styles/pages/partials/_article-headings.scss
 create mode 100644 src/styles/pages/partials/_article-links.scss
 create mode 100644 src/styles/pages/partials/_article-lists.scss
 create mode 100644 src/styles/pages/partials/_article-media.scss
 create mode 100644 src/styles/pages/partials/_article-prism.scss
 create mode 100644 src/styles/pages/partials/_article-wp-blocks.scss
 create mode 100644 src/styles/pages/topic.module.scss
 create mode 100644 src/utils/hooks/use-add-prism-class-attr.tsx

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/components/atoms/forms/form.tsx b/src/components/atoms/forms/form.tsx
index ef8dce4..b819aea 100644
--- a/src/components/atoms/forms/form.tsx
+++ b/src/components/atoms/forms/form.tsx
@@ -35,7 +35,6 @@ export type FormProps = {
  */
 const Form: FC<FormProps> = ({
   children,
-  className = '',
   grouped = true,
   onSubmit,
   ...props
@@ -68,7 +67,7 @@ const Form: FC<FormProps> = ({
   };
 
   return (
-    <form onSubmit={handleSubmit} className={className} {...props}>
+    <form onSubmit={handleSubmit} {...props}>
       {getFormItems()}
     </form>
   );
diff --git a/src/components/atoms/links/link.module.scss b/src/components/atoms/links/link.module.scss
index 5c97bd2..1b89727 100644
--- a/src/components/atoms/links/link.module.scss
+++ b/src/components/atoms/links/link.module.scss
@@ -2,29 +2,6 @@
 @use "@styles/abstracts/variables" as var;
 
 .link {
-  background: linear-gradient(to top, var(--color-primary) 50%, transparent 50%)
-    0 0 / 100% 201% no-repeat;
-  color: var(--color-primary);
-  text-decoration-thickness: 0.15em;
-  text-underline-offset: 20%;
-  transition: all 0.3s linear 0s, text-decoration 0.18s ease-in-out 0s;
-
-  &:hover {
-    color: var(--color-primary-light);
-    text-decoration-thickness: 0.25em;
-  }
-
-  &:focus {
-    background-position: 0 100%;
-    color: var(--color-fg-inverted);
-  }
-
-  &:active {
-    background-position: 0 0;
-    color: var(--color-primary-dark);
-    text-decoration-thickness: 18%;
-  }
-
   &[hreflang] {
     &::after {
       display: inline-block;
diff --git a/src/components/atoms/lists/description-list-item.module.scss b/src/components/atoms/lists/description-list-item.module.scss
index 60cad57..aba90ce 100644
--- a/src/components/atoms/lists/description-list-item.module.scss
+++ b/src/components/atoms/lists/description-list-item.module.scss
@@ -27,6 +27,8 @@
   }
 
   &--inline-values {
+    row-gap: var(--spacing-2xs);
+
     .term {
       flex: 1 1 100%;
     }
diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx
index 1f6219a..74bd4ff 100644
--- a/src/components/molecules/layout/meta.tsx
+++ b/src/components/molecules/layout/meta.tsx
@@ -92,13 +92,17 @@ export type MetaData = {
    */
   topics?: string[] | JSX.Element[];
   /**
-   * A total.
+   * A total number of posts.
    */
-  total?: string;
+  total?: number;
   /**
    * The update date.
    */
   update?: MetaDate;
+  /**
+   * An url.
+   */
+  website?: string;
 };
 
 export type MetaKey = keyof MetaData;
@@ -145,80 +149,86 @@ const Meta: FC<MetaProps> = ({
       case 'author':
         return intl.formatMessage({
           defaultMessage: 'Written by:',
-          id: 'OI0N37',
           description: 'Meta: author label',
+          id: 'OI0N37',
         });
       case 'comments':
         return intl.formatMessage({
           defaultMessage: 'Comments:',
-          id: 'jTVIh8',
           description: 'Meta: comments label',
+          id: 'jTVIh8',
         });
       case 'creation':
         return intl.formatMessage({
           defaultMessage: 'Created on:',
-          id: 'b4fdYE',
           description: 'Meta: creation date label',
+          id: 'b4fdYE',
         });
       case 'license':
         return intl.formatMessage({
           defaultMessage: 'License:',
-          id: 'AuGklx',
           description: 'Meta: license label',
+          id: 'AuGklx',
         });
       case 'popularity':
         return intl.formatMessage({
           defaultMessage: 'Popularity:',
-          id: 'pWTj2W',
           description: 'Meta: popularity label',
+          id: 'pWTj2W',
         });
       case 'publication':
         return intl.formatMessage({
           defaultMessage: 'Published on:',
-          id: 'QGi5uD',
           description: 'Meta: publication date label',
+          id: 'QGi5uD',
         });
       case 'readingTime':
         return intl.formatMessage({
           defaultMessage: 'Reading time:',
-          id: 'EbFvsM',
           description: 'Meta: reading time label',
+          id: 'EbFvsM',
         });
       case 'repositories':
         return intl.formatMessage({
           defaultMessage: 'Repositories:',
-          id: 'DssFG1',
           description: 'Meta: repositories label',
+          id: 'DssFG1',
         });
       case 'technologies':
         return intl.formatMessage({
           defaultMessage: 'Technologies:',
-          id: 'ADQmDF',
           description: 'Meta: technologies label',
+          id: 'ADQmDF',
         });
       case 'thematics':
         return intl.formatMessage({
           defaultMessage: 'Thematics:',
-          id: 'bz53Us',
           description: 'Meta: thematics label',
+          id: 'bz53Us',
         });
       case 'topics':
         return intl.formatMessage({
           defaultMessage: 'Topics:',
-          id: 'gJNaBD',
           description: 'Meta: topics label',
+          id: 'gJNaBD',
         });
       case 'total':
         return intl.formatMessage({
           defaultMessage: 'Total:',
-          id: '92zgdp',
           description: 'Meta: total label',
+          id: '92zgdp',
         });
       case 'update':
         return intl.formatMessage({
           defaultMessage: 'Updated on:',
-          id: 'tLC7bh',
           description: 'Meta: update date label',
+          id: 'tLC7bh',
+        });
+      case 'website':
+        return intl.formatMessage({
+          defaultMessage: 'Official website:',
+          description: 'Meta: official website label',
+          id: 'GRyyfy',
         });
       default:
         return '';
@@ -279,8 +289,8 @@ const Meta: FC<MetaProps> = ({
       {
         defaultMessage:
           '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>',
-        id: '02rgLO',
         description: 'Meta: comments count',
+        id: '02rgLO',
       },
       {
         a11y: (chunks: ReactNode) => (
@@ -316,6 +326,23 @@ const Meta: FC<MetaProps> = ({
       case 'publication':
       case 'update':
         return getDate(value as MetaDate);
+      case 'total':
+        return intl.formatMessage(
+          {
+            defaultMessage:
+              '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
+            description: 'BlogPage: posts count meta',
+            id: 'OF5cPz',
+          },
+          { postsCount: value as number }
+        );
+      case 'website':
+        const url = value as string;
+        return (
+          <Link href={url} external={true}>
+            {url}
+          </Link>
+        );
       default:
         return value as string | ReactNode | ReactNode[];
     }
diff --git a/src/components/molecules/layout/page-footer.tsx b/src/components/molecules/layout/page-footer.tsx
index e998b1e..97e449f 100644
--- a/src/components/molecules/layout/page-footer.tsx
+++ b/src/components/molecules/layout/page-footer.tsx
@@ -19,7 +19,9 @@ export type PageFooterProps = {
  */
 const PageFooter: FC<PageFooterProps> = ({ meta, ...props }) => {
   return (
-    <footer {...props}>{meta && <Meta data={meta} layout="column" />}</footer>
+    <footer {...props}>
+      {meta && <Meta data={meta} withSeparator={false} />}
+    </footer>
   );
 };
 
diff --git a/src/components/molecules/layout/page-header.module.scss b/src/components/molecules/layout/page-header.module.scss
index 4c7df5f..232023a 100644
--- a/src/components/molecules/layout/page-header.module.scss
+++ b/src/components/molecules/layout/page-header.module.scss
@@ -56,3 +56,9 @@
 .meta {
   font-size: var(--font-size-sm);
 }
+
+.intro {
+  > *:last-child {
+    margin-bottom: 0;
+  }
+}
diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx
index 9abe9af..6759c7f 100644
--- a/src/components/molecules/layout/page-header.tsx
+++ b/src/components/molecules/layout/page-header.tsx
@@ -1,5 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
-import { FC } from 'react';
+import { FC, ReactNode } from 'react';
 import Meta, { type MetaData } from './meta';
 import styles from './page-header.module.scss';
 
@@ -19,7 +19,7 @@ export type PageHeaderProps = {
   /**
    * The page title.
    */
-  title: string;
+  title: ReactNode;
 };
 
 /**
@@ -35,9 +35,12 @@ const PageHeader: FC<PageHeaderProps> = ({
 }) => {
   const getIntro = () => {
     return typeof intro === 'string' ? (
-      <div dangerouslySetInnerHTML={{ __html: intro }} />
+      <div
+        className={styles.intro}
+        dangerouslySetInnerHTML={{ __html: intro }}
+      />
     ) : (
-      <div>{intro}</div>
+      <div className={styles.intro}>{intro}</div>
     );
   };
 
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
index 9e0abdf..5ff4ea4 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -1,5 +1,5 @@
 import Button from '@components/atoms/buttons/button';
-import Form from '@components/atoms/forms/form';
+import Form, { type FormProps } from '@components/atoms/forms/form';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import Spinner from '@components/atoms/loaders/spinner';
 import LabelledField from '@components/molecules/forms/labelled-field';
@@ -15,11 +15,7 @@ export type CommentFormData = {
   website?: string;
 };
 
-export type CommentFormProps = {
-  /**
-   * Set additional classnames to the form wrapper.
-   */
-  className?: string;
+export type CommentFormProps = Pick<FormProps, 'className'> & {
   /**
    * Pass a component to print a success/error message.
    */
@@ -44,12 +40,12 @@ export type CommentFormProps = {
 };
 
 const CommentForm: FC<CommentFormProps> = ({
-  className = '',
   Notice,
   parentId,
   saveComment,
   title,
   titleLevel = 2,
+  ...props
 }) => {
   const intl = useIntl();
   const [name, setName] = useState<string>('');
@@ -116,9 +112,9 @@ const CommentForm: FC<CommentFormProps> = ({
   return (
     <Form
       onSubmit={submitHandler}
-      className={className}
       aria-label={formAriaLabel}
       aria-labelledby={formLabelledBy}
+      {...props}
     >
       {title && (
         <Heading id={formId} level={titleLevel}>
diff --git a/src/components/organisms/widgets/sharing.stories.tsx b/src/components/organisms/widgets/sharing.stories.tsx
index 47213b6..59b86d3 100644
--- a/src/components/organisms/widgets/sharing.stories.tsx
+++ b/src/components/organisms/widgets/sharing.stories.tsx
@@ -1,5 +1,4 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { IntlProvider } from 'react-intl';
 import SharingWidget from './sharing';
 
 /**
@@ -9,6 +8,19 @@ export default {
   title: 'Organisms/Widgets',
   component: SharingWidget,
   argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the sharing links list.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     data: {
       description: 'The page data.',
       type: {
@@ -58,13 +70,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof SharingWidget>;
 
 const Template: ComponentStory<typeof SharingWidget> = (args) => (
diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx
index 85dadb0..c63f5db 100644
--- a/src/components/organisms/widgets/sharing.tsx
+++ b/src/components/organisms/widgets/sharing.tsx
@@ -22,6 +22,10 @@ export type SharingData = {
 };
 
 export type SharingProps = {
+  /**
+   * Set additional classnames to the sharing links list.
+   */
+  className?: string;
   /**
    * The page data to share.
    */
@@ -46,6 +50,7 @@ export type SharingProps = {
  * Render a list of sharing links inside a widget.
  */
 const Sharing: FC<SharingProps> = ({
+  className = '',
   data,
   media,
   expanded = true,
@@ -201,7 +206,7 @@ const Sharing: FC<SharingProps> = ({
 
   return (
     <Widget expanded={expanded} level={level} title={widgetTitle} {...props}>
-      <ul className={styles.list}>{getItems()}</ul>
+      <ul className={`${styles.list} ${className}`}>{getItems()}</ul>
     </Widget>
   );
 };
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
index 7602492..83e5a80 100644
--- a/src/components/templates/page/page-layout.module.scss
+++ b/src/components/templates/page/page-layout.module.scss
@@ -72,6 +72,7 @@
 
 .footer {
   grid-column: 2;
+  margin: var(--spacing-sm) 0 var(--spacing-2xs);
 }
 
 .comments {
@@ -87,4 +88,9 @@
     grid-column: 2;
     margin: var(--spacing-md) 0 0;
   }
+
+  &__form {
+    max-width: 40ch;
+    margin: auto;
+  }
 }
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 002b951..8af5f98 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -473,7 +473,7 @@ export const Blog = Template.bind({});
 Blog.args = {
   breadcrumb: postsListBreadcrumb,
   title: 'Blog',
-  headerMeta: { total: `${posts.length} posts` },
+  headerMeta: { total: posts.length },
   children: (
     <>
       <PostsList posts={posts} byYear={true} total={posts.length} />
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index bc90f4c..f3f3ea8 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -20,7 +20,7 @@ import TableOfContents from '@components/organisms/widgets/table-of-contents';
 import { type SendCommentVars } from '@services/graphql/api';
 import { sendComment } from '@services/graphql/comments';
 import useIsMounted from '@utils/hooks/use-is-mounted';
-import { FC, ReactNode, useRef, useState } from 'react';
+import { FC, HTMLAttributes, ReactNode, useRef, useState } from 'react';
 import { useIntl } from 'react-intl';
 import Layout, { type LayoutProps } from '../layout/layout';
 import styles from './page-layout.module.scss';
@@ -33,6 +33,11 @@ export type PageLayoutProps = Pick<
    * True if the page accepts new comments. Default: false.
    */
   allowComments?: boolean;
+  bodyAttributes?: HTMLAttributes<HTMLDivElement>;
+  /**
+   * Set additional classnames to the body wrapper.
+   */
+  bodyClassName?: string;
   /**
    * The breadcrumb items.
    */
@@ -83,6 +88,8 @@ export type PageLayoutProps = Pick<
 const PageLayout: FC<PageLayoutProps> = ({
   children,
   allowComments = false,
+  bodyAttributes,
+  bodyClassName = '',
   breadcrumb,
   breadcrumbSchema,
   comments,
@@ -91,8 +98,8 @@ const PageLayout: FC<PageLayoutProps> = ({
   id,
   intro,
   isHome = false,
-  widgets,
   title,
+  widgets,
   withToC = false,
 }) => {
   const intl = useIntl();
@@ -202,11 +209,12 @@ const PageLayout: FC<PageLayoutProps> = ({
       {typeof children === 'string' ? (
         <div
           ref={bodyRef}
-          className={styles.body}
+          className={`${styles.body} ${bodyClassName}`}
           dangerouslySetInnerHTML={{ __html: children }}
+          {...bodyAttributes}
         />
       ) : (
-        <div ref={bodyRef} className={styles.body}>
+        <div ref={bodyRef} className={`${styles.body} ${bodyClassName}`}>
           {children}
         </div>
       )}
@@ -245,6 +253,7 @@ const PageLayout: FC<PageLayoutProps> = ({
           {allowComments && (
             <section className={styles.comments__section}>
               <CommentForm
+                className={styles.comments__form}
                 saveComment={saveComment}
                 title={commentFormTitle}
                 Notice={
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 5eeabd9..995e3a9 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -1,5 +1,6 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Link from '@components/atoms/links/link';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Sharing from '@components/organisms/widgets/sharing';
 import PageLayout, {
   type PageLayoutProps,
@@ -9,15 +10,20 @@ import {
   getArticleBySlug,
 } from '@services/graphql/articles';
 import { getPostComments } from '@services/graphql/comments';
+import styles from '@styles/pages/article.module.scss';
 import { type Article, type Comment } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import useAddPrismClassAttr from '@utils/hooks/use-add-prism-class-attr';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
+import usePrismPlugins, { PrismPlugin } from '@utils/hooks/use-prism-plugins';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
+import { HTMLAttributes } from 'react';
+import { useIntl } from 'react-intl';
 import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
 import useSWR from 'swr';
 
@@ -54,16 +60,31 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
       )),
   };
 
+  const intl = useIntl();
+  const footerMetaLabel = intl.formatMessage({
+    defaultMessage: 'Read more articles about:',
+    description: 'ArticlePage: footer topics list label',
+    id: '50xc4o',
+  });
+
   const footerMeta: PageLayoutProps['footerMeta'] = {
-    topics:
-      topics &&
-      topics.map((topic) => {
+    custom: topics && {
+      label: footerMetaLabel,
+      value: topics.map((topic) => {
         return (
-          <ButtonLink key={topic.id} target={`/sujet/${topic.slug}`}>
+          <ButtonLink
+            key={topic.id}
+            target={`/sujet/${topic.slug}`}
+            className={styles.btn}
+          >
+            {topic.logo && (
+              <ResponsiveImage className={styles.btn__icon} {...topic.logo} />
+            )}{' '}
             {topic.name}
           </ButtonLink>
         );
       }),
+    },
   };
 
   const { website } = useSettings();
@@ -156,6 +177,15 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
     });
   };
 
+  const prismPlugins: PrismPlugin[] = ['command-line', 'line-numbers'];
+  const { pluginsAttribute, pluginsClassName } = usePrismPlugins(prismPlugins);
+  useAddPrismClassAttr({
+    attributes: {
+      'data-filter-output': '#output#"',
+    },
+    classNames: pluginsClassName,
+  });
+
   return (
     <>
       <Head>
@@ -173,6 +203,10 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
       />
       <PageLayout
         allowComments={true}
+        bodyAttributes={{
+          ...(pluginsAttribute as HTMLAttributes<HTMLDivElement>),
+        }}
+        bodyClassName={styles.body}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
         comments={data && getCommentsList(data)}
@@ -185,6 +219,7 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
         widgets={[
           <Sharing
             key="sharing-widget"
+            className={styles.widget}
             data={{ excerpt: intro, title, url: pageUrl }}
             media={[
               'diaspora',
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index a5ef045..b6ce221 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -114,16 +114,6 @@ const BlogPage: NextPage<BlogPageProps> = ({
     '@graph': [webpageSchema, blogSchema],
   };
 
-  const postsCount = intl.formatMessage(
-    {
-      defaultMessage:
-        '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
-      id: 'OF5cPz',
-      description: 'BlogPage: posts count meta',
-    },
-    { postsCount: totalArticles }
-  );
-
   /**
    * Retrieve the formatted meta.
    *
@@ -231,7 +221,7 @@ const BlogPage: NextPage<BlogPageProps> = ({
         title={title}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        headerMeta={{ total: postsCount }}
+        headerMeta={{ total: totalArticles }}
         widgets={[
           <LinksListWidget
             key="thematics-list"
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index b3dec10..7936c84 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -1,4 +1,6 @@
+import Heading from '@components/atoms/headings/heading';
 import Link from '@components/atoms/links/link';
+import List from '@components/atoms/lists/list';
 import ImageWidget from '@components/organisms/widgets/image-widget';
 import SocialMedia from '@components/organisms/widgets/social-media';
 import PageLayout, {
@@ -9,11 +11,12 @@ import styles from '@styles/pages/cv.module.scss';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
+import { NestedMDXComponents } from 'mdx/types';
 import { GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
-import { ReactNode } from 'react';
+import React, { ReactNode } from 'react';
 import { useIntl } from 'react-intl';
 import { AboutPage, Graph, WebPage } from 'schema-dts';
 
@@ -141,6 +144,18 @@ const CVPage: NextPage = () => {
     '@graph': [webpageSchema, cvSchema],
   };
 
+  const components: NestedMDXComponents = {
+    a: (props) => <Link external={true} {...props} />,
+    h1: (props) => <Heading level={1} {...props} />,
+    h2: (props) => <Heading level={2} {...props} />,
+    h3: (props) => <Heading level={3} {...props} />,
+    h4: (props) => <Heading level={4} {...props} />,
+    h5: (props) => <Heading level={5} {...props} />,
+    h6: (props) => <Heading level={6} {...props} />,
+    Link: (props) => <Link {...props} />,
+    List: (props) => <List {...props} />,
+  };
+
   return (
     <PageLayout
       breadcrumb={breadcrumbItems}
@@ -166,7 +181,7 @@ const CVPage: NextPage = () => {
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
-      <CVContent />
+      <CVContent components={components} />
     </PageLayout>
   );
 };
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index 0a7dc60..d88a293 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -140,16 +140,6 @@ const SearchPage: NextPage<SearchPageProps> = ({
     getTotalArticles(query.s as string)
   );
 
-  const postsCount = intl.formatMessage(
-    {
-      defaultMessage:
-        '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}',
-      id: 'LtsVOx',
-      description: 'SearchPage: posts count meta',
-    },
-    { postsCount: totalArticles || 0 }
-  );
-
   /**
    * Retrieve the formatted meta.
    *
@@ -244,7 +234,7 @@ const SearchPage: NextPage<SearchPageProps> = ({
         title={title}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        headerMeta={{ total: postsCount }}
+        headerMeta={{ total: totalArticles }}
         widgets={[
           <LinksListWidget
             key="thematics-list"
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 447d969..348fe05 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -1,4 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
+import ResponsiveImage from '@components/molecules/images/responsive-image';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import PageLayout, {
@@ -10,6 +11,7 @@ import {
   getTopicsPreview,
   getTotalTopics,
 } from '@services/graphql/topics';
+import styles from '@styles/pages/topic.module.scss';
 import { type Article, type PageLink, type Topic } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
@@ -35,7 +37,14 @@ export type TopicPageProps = {
 
 const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   const { content, intro, meta, slug, title } = currentTopic;
-  const { articles, dates, seo, thematics } = meta;
+  const {
+    articles,
+    cover,
+    dates,
+    seo,
+    thematics,
+    website: officialWebsite,
+  } = meta;
   const intl = useIntl();
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
     title,
@@ -45,6 +54,8 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
     update: dates.update ? { date: dates.update } : undefined,
+    website: officialWebsite,
+    total: articles ? articles.length : undefined,
   };
 
   const { website } = useSettings();
@@ -98,10 +109,10 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
         ...remainingData
       } = article;
 
-      const { cover, ...remainingMeta } = articleMeta;
+      const { cover: articleCover, ...remainingMeta } = articleMeta;
 
       return {
-        cover,
+        cover: articleCover,
         excerpt: articleIntro,
         meta: getPostMeta(remainingMeta),
         url: `/article/${articleSlug}`,
@@ -122,6 +133,15 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
     id: '/sRqPT',
   });
 
+  const getPageHeading = () => {
+    return (
+      <>
+        {cover && <ResponsiveImage className={styles.logo} {...cover} />}
+        {title}
+      </>
+    );
+  };
+
   return (
     <>
       <Head>
@@ -140,7 +160,7 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
       <PageLayout
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        title={title}
+        title={getPageHeading()}
         intro={intro}
         headerMeta={headerMeta}
         widgets={
@@ -206,14 +226,15 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({
   const allTopics = allTopicsEdges.edges.map((edge) =>
     getPageLinkFromRawData(edge.node)
   );
+  const topicsLinks = allTopics.filter(
+    (topic) => topic.slug !== (params!.slug as TopicParams['slug'])
+  );
   const translation = await loadTranslation(locale);
 
   return {
     props: {
       currentTopic: JSON.parse(JSON.stringify(currentTopic)),
-      topics: allTopics.filter(
-        (topic) => topic.slug !== (params!.slug as TopicParams['slug'])
-      ),
+      topics: JSON.parse(JSON.stringify(topicsLinks)),
       translation,
     },
   };
diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts
index d65bc9e..e02ca8e 100644
--- a/src/services/graphql/articles.query.ts
+++ b/src/services/graphql/articles.query.ts
@@ -14,6 +14,17 @@ export const articleBySlugQuery = `query PostBy($slug: ID!) {
       postsInTopic {
         ... on Topic {
           databaseId
+          featuredImage {
+            node {
+              altText
+              mediaDetails {
+                height
+                width
+              }
+              sourceUrl
+              title
+            }
+          }
           slug
           title
         }
diff --git a/src/services/graphql/topics.query.ts b/src/services/graphql/topics.query.ts
index 19be5d7..4574256 100644
--- a/src/services/graphql/topics.query.ts
+++ b/src/services/graphql/topics.query.ts
@@ -94,6 +94,17 @@ export const topicsListQuery = `query TopicsList($after: String = "", $first: In
       cursor
       node {
         databaseId
+        featuredImage {
+          node {
+            altText
+            mediaDetails {
+              height
+              width
+            }
+            sourceUrl
+            title
+          }
+        }
         slug
         title
       }
diff --git a/src/styles/base/_typography.scss b/src/styles/base/_typography.scss
index 7b7a695..2c3c8cc 100644
--- a/src/styles/base/_typography.scss
+++ b/src/styles/base/_typography.scss
@@ -120,21 +120,15 @@ dl {
   & & {
     margin: var(--spacing-2xs) 0 0;
   }
-}
-
-dt {
-  flex: 0 0 max-content;
-  font-weight: 600;
-}
 
-dd {
-  flex: 0 0 auto;
-  margin: 0;
+  ::marker {
+    color: var(--color-primary-dark);
+  }
 }
 
 a {
   background: linear-gradient(to top, var(--color-primary) 50%, transparent 50%)
-    0 0 / 100% 200% no-repeat;
+    0 0 / 100% 201% no-repeat;
   color: var(--color-primary);
   text-decoration-thickness: 0.15em;
   text-underline-offset: 20%;
@@ -199,13 +193,3 @@ pre {
   word-break: normal;
   word-wrap: normal;
 }
-
-figure {
-  margin: var(--spacing-md) 0;
-}
-
-figcaption {
-  margin-top: var(--spacing-xs);
-  font-size: var(--font-size-sm);
-  text-align: center;
-}
diff --git a/src/styles/components/_wp-blocks.scss b/src/styles/components/_wp-blocks.scss
deleted file mode 100644
index efd6db5..0000000
--- a/src/styles/components/_wp-blocks.scss
+++ /dev/null
@@ -1,166 +0,0 @@
-@use "@styles/abstracts/functions" as fun;
-@use "@styles/abstracts/mixins" as mix;
-@use "@styles/abstracts/placeholders";
-
-.wp-block-quote {
-  margin: var(--spacing-sm) 0;
-  padding: var(--spacing-sm);
-  position: relative;
-  border: fun.convert-px(1) solid var(--color-primary-lighter);
-  border-left: fun.convert-px(5) solid var(--color-primary-lighter);
-  box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-    fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-      var(--color-shadow-light),
-    fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-      var(--color-shadow-light);
-  font-style: italic;
-
-  > *:last-child {
-    margin: 0;
-  }
-
-  cite {
-    font-size: var(--font-size-sm);
-    font-style: normal;
-    font-weight: 600;
-  }
-}
-
-.wp-block-code,
-.wp-block-preformatted {
-  margin: 0 auto var(--spacing-md);
-  padding: var(--spacing-xs) var(--spacing-sm);
-  background: var(--color-bg-secondary);
-  border: fun.convert-px(1) solid var(--color-border-light);
-  color: var(--color-fg);
-}
-
-.wp-block-columns {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr);
-  gap: var(--spacing-md);
-  margin: var(--spacing-md) 0;
-
-  @include mix.media("screen") {
-    @include mix.dimensions("sm") {
-      grid-template-columns: repeat(2, minmax(0, 1fr));
-    }
-  }
-
-  &.are-vertically-aligned-center {
-    align-items: center;
-  }
-}
-
-.wp-block-column {
-  > *:first-child {
-    margin-top: 0;
-  }
-
-  > *:last-child {
-    margin-bottom: 0;
-  }
-}
-
-.wp-block-gallery {
-  display: grid;
-  grid-template-columns: minmax(0, 1fr);
-  gap: var(--spacing-sm);
-
-  .blocks-gallery-grid {
-    @extend %reset-list;
-
-    grid-column: 1 / -1;
-    grid-row: 1 / -1;
-    display: grid;
-    grid-template-columns: minmax(0, 1fr);
-    gap: var(--spacing-sm);
-  }
-
-  .blocks-gallery-item {
-    figure {
-      margin: 0;
-    }
-
-    a {
-      display: block;
-      box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-        fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-          var(--color-shadow-light),
-        fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-          var(--color-shadow-light);
-
-      &:hover,
-      &:focus {
-        transform: scale(1.05);
-        box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-          fun.convert-px(3) fun.convert-px(3) fun.convert-px(2) 0
-            var(--color-shadow-light),
-          fun.convert-px(5) fun.convert-px(5) fun.convert-px(8) 0
-            var(--color-shadow-light);
-      }
-
-      &:focus {
-        outline: solid var(--color-primary-light);
-      }
-
-      &:active {
-        transform: scale(0.95);
-        box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-          fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-            var(--color-shadow-light),
-          0 0 0 0 var(--color-shadow-light);
-        outline: none;
-      }
-    }
-  }
-
-  &.aligncenter {
-    .blocks-gallery-grid {
-      align-items: center;
-    }
-  }
-
-  @for $i from 0 to 6 {
-    &.columns-#{$i} {
-      @include mix.media("screen") {
-        @include mix.dimensions("xs") {
-          grid-template-columns: repeat(2, minmax(0, 1fr));
-
-          .blocks-gallery-grid {
-            grid-template-columns: repeat(2, minmax(0, 1fr));
-          }
-        }
-
-        @include mix.dimensions("sm") {
-          grid-template-columns: repeat(#{$i}, minmax(0, 1fr));
-
-          .blocks-gallery-grid {
-            grid-template-columns: repeat(3, minmax(0, 1fr));
-          }
-        }
-      }
-    }
-  }
-}
-
-.wp-block-image {
-  img {
-    display: block;
-    margin: auto;
-    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-        var(--color-shadow-light),
-      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-        var(--color-shadow-light);
-    text-align: center;
-  }
-}
-
-.wp-block-video {
-  box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-    fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-      var(--color-shadow-light),
-    fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-      var(--color-shadow-light);
-}
diff --git a/src/styles/globals.scss b/src/styles/globals.scss
index f9a1281..8ece909 100644
--- a/src/styles/globals.scss
+++ b/src/styles/globals.scss
@@ -6,7 +6,6 @@
  * Import each files separately to define vendors styles order.
  */
 @use "modern-normalize";
-@use "vendors/prism";
 
 /**
  * 2.0. Base
@@ -22,14 +21,7 @@
 @use "base/typography";
 
 /**
- * 3.0. Components
- *
- * Define styles for external components (like WordPress blocks).
- */
-@use "components/wp-blocks";
-
-/**
- * 4.0. Themes
+ * 3.0. Themes
  *
  * Define themes specific styles.
  */
diff --git a/src/styles/pages/article.module.scss b/src/styles/pages/article.module.scss
new file mode 100644
index 0000000..a42c633
--- /dev/null
+++ b/src/styles/pages/article.module.scss
@@ -0,0 +1,36 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/variables" as var;
+@use "partials/article-links";
+@use "partials/article-lists";
+@use "partials/article-media";
+@use "partials/article-prism";
+@use "partials/article-wp-blocks";
+
+.btn {
+  margin-right: var(--spacing-2xs);
+  padding: var(--spacing-2xs) var(--spacing-xs);
+
+  &__icon {
+    max-width: fun.convert-px(22);
+    margin-right: var(--spacing-2xs);
+  }
+}
+
+.body {
+  :global {
+    @include article-links.styles;
+    @include article-lists.styles;
+    @include article-media.styles;
+    @include article-prism.styles;
+    @include article-wp-blocks.styles;
+  }
+}
+
+.widget {
+  @include mix.media("screen") {
+    @include mix.dimensions("md") {
+      width: min-content;
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-headings.scss b/src/styles/pages/partials/_article-headings.scss
new file mode 100644
index 0000000..c0c3519
--- /dev/null
+++ b/src/styles/pages/partials/_article-headings.scss
@@ -0,0 +1,57 @@
+@use "@styles/abstracts/functions" as fun;
+
+@mixin styles {
+  h1 {
+    font-size: var(--font-size-3xl);
+    font-weight: 500;
+  }
+
+  h2 {
+    padding-bottom: fun.convert-px(3);
+    background: linear-gradient(
+        to top,
+        var(--color-primary-dark) 0.3rem,
+        transparent 0.3rem
+      )
+      0 0 / 3rem 100% no-repeat;
+    font-size: var(--font-size-2xl);
+    font-weight: 500;
+    text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light);
+  }
+
+  h3 {
+    font-size: var(--font-size-xl);
+    font-weight: 500;
+  }
+
+  h4 {
+    font-size: var(--font-size-lg);
+    font-weight: 500;
+  }
+
+  h5 {
+    font-size: var(--font-size-md);
+    font-weight: 600;
+  }
+
+  h6 {
+    font-size: var(--font-size-md);
+    font-weight: 500;
+  }
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    color: var(--color-primary-dark);
+    font-family: var(--font-family-secondary);
+    letter-spacing: 0.01ex;
+    margin: 0 0 var(--spacing-sm);
+
+    & + & {
+      margin-top: var(--spacing-md);
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-links.scss b/src/styles/pages/partials/_article-links.scss
new file mode 100644
index 0000000..df86dcf
--- /dev/null
+++ b/src/styles/pages/partials/_article-links.scss
@@ -0,0 +1,104 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/variables" as var;
+
+@mixin styles {
+  a {
+    &[hreflang] {
+      &::after {
+        display: inline-block;
+
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]";
+        font-size: var(--font-size-sm);
+      }
+    }
+  }
+
+  a.download {
+    &::after {
+      display: inline-block;
+
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+    }
+
+    &:focus:not(:active)::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_white}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>'));
+      }
+    }
+  }
+
+  a.external {
+    &::after {
+      display: inline-block;
+
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+    }
+
+    &:focus:not(:active)::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+
+      &:focus:not(:active)::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+    }
+  }
+
+  a.external.download {
+    &::after {
+      /* Prettier is removing spacing between content parts. */
+
+      /* prettier-ignore */
+      content: "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+    }
+
+    &[hreflang] {
+      &::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_blue}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+
+      &:focus:not(:active)::after {
+        /* Prettier is removing spacing between content parts. */
+
+        /* prettier-ignore */
+        content: "\0000a0[" attr(hreflang) "]\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_blue}" d="m49 80.048-28.445-30.77 19.32 4.095V5.06h18.252v48.313l21.318-4.095z"/><path fill="#{var.$light-theme_blue}" d="M0 67.57v27.37h100V67.57H87.973v15.344H12.027V67.569z"/></svg>')) "\0000a0" url(fun.encode-svg('<svg width="13" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path fill="#{var.$light-theme_white}" d="M100 0 59.543 5.887l20.8 6.523-51.134 51.134 7.249 7.248L87.59 19.66l6.522 20.798z"/><path fill="#{var.$light-theme_white}" d="M4 10a4 4 0 0 0-4 4v82a4 4 0 0 0 4 4h82a4 4 0 0 0 4-4V62.314h-8V92H8V18h29.686v-8z"/></svg>'));
+      }
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-lists.scss b/src/styles/pages/partials/_article-lists.scss
new file mode 100644
index 0000000..c0084b0
--- /dev/null
+++ b/src/styles/pages/partials/_article-lists.scss
@@ -0,0 +1,65 @@
+@mixin styles {
+  ol {
+    padding: 0;
+    list-style-type: none;
+    counter-reset: li;
+
+    > li {
+      display: table;
+      counter-increment: li;
+
+      &::before {
+        content: counters(li, ".") ". ";
+        display: table-cell;
+        padding-right: var(--spacing-2xs);
+        color: var(--color-secondary);
+      }
+    }
+
+    li ol > li::before {
+      content: counters(li, ".") ". ";
+    }
+  }
+
+  ul,
+  ol {
+    li:not(:last-child) {
+      margin-bottom: var(--spacing-2xs);
+    }
+
+    ::marker {
+      color: var(--color-primary-dark);
+    }
+  }
+
+  ul {
+    padding-left: var(--spacing-sm);
+  }
+
+  dl {
+    display: flex;
+    flex-flow: row wrap;
+    gap: var(--spacing-2xs);
+    width: fit-content;
+  }
+
+  ul,
+  ol,
+  dl {
+    margin: var(--spacing-sm) 0;
+
+    & & {
+      margin: var(--spacing-2xs) 0 0;
+    }
+  }
+
+  dt {
+    color: var(--color-fg-light);
+    font-weight: 600;
+  }
+
+  dd {
+    margin: 0;
+    word-break: break-all;
+  }
+}
diff --git a/src/styles/pages/partials/_article-media.scss b/src/styles/pages/partials/_article-media.scss
new file mode 100644
index 0000000..8359881
--- /dev/null
+++ b/src/styles/pages/partials/_article-media.scss
@@ -0,0 +1,11 @@
+@mixin styles {
+  figure {
+    margin: var(--spacing-md) 0;
+  }
+
+  figcaption {
+    margin-top: var(--spacing-xs);
+    font-size: var(--font-size-sm);
+    text-align: center;
+  }
+}
diff --git a/src/styles/pages/partials/_article-prism.scss b/src/styles/pages/partials/_article-prism.scss
new file mode 100644
index 0000000..025e0c0
--- /dev/null
+++ b/src/styles/pages/partials/_article-prism.scss
@@ -0,0 +1,301 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+
+@mixin styles {
+  .code-toolbar {
+    --gutter-size: clamp(#{fun.convert-px(75)}, 20vw, #{fun.convert-px(90)});
+    --toolbar-height: #{fun.convert-px(90)};
+
+    position: relative;
+    margin-top: calc(var(--toolbar-height) + var(--spacing-md));
+
+    @include mix.media("screen") {
+      @include mix.dimensions("2xs") {
+        --toolbar-height: #{fun.convert-px(60)};
+      }
+    }
+
+    .toolbar {
+      display: grid;
+      grid-template-columns: max-content minmax(0, 1fr);
+      justify-items: end;
+      width: 100%;
+      height: var(--toolbar-height);
+      position: absolute;
+      top: calc(var(--toolbar-height) * -1);
+      left: 0;
+      right: 0;
+      background: var(--color-bg-tertiary);
+      border: fun.convert-px(1) solid var(--color-border);
+
+      @include mix.media("screen") {
+        @include mix.dimensions("2xs") {
+          display: flex;
+          flex-flow: row wrap;
+        }
+      }
+    }
+
+    .toolbar-item {
+      display: flex;
+      align-items: center;
+    }
+
+    .toolbar-item:nth-child(1) {
+      grid-column: 1;
+      grid-row: 1 / 3;
+      margin-right: auto;
+      padding: 0 var(--spacing-sm);
+      background: var(--color-bg-code);
+      border-right: fun.convert-px(1) solid var(--color-border);
+      color: var(--color-primary-darker);
+      font-size: var(--font-size-sm);
+      font-weight: 600;
+    }
+
+    .toolbar-item:nth-child(2) {
+      grid-column: 2;
+      grid-row: 1;
+      margin: 0 var(--spacing-2xs);
+    }
+
+    .toolbar-item:nth-child(3) {
+      grid-column: 2;
+      grid-row: 2;
+      margin: 0 var(--spacing-2xs);
+    }
+  }
+
+  pre[class*="language-"] {
+    max-height: max(30vw, fun.convert-px(300));
+    margin: var(--spacing-md) 0;
+    padding: 0;
+    position: relative;
+    background: var(--color-bg-secondary);
+    color: var(--color-fg);
+    border: fun.convert-px(1) solid var(--color-border);
+
+    > code {
+      display: block;
+      padding: var(--spacing-xs) 0 var(--spacing-xs)
+        calc(var(--gutter-size) + var(--spacing-xs));
+    }
+
+    .line-numbers-rows,
+    .command-line-prompt {
+      width: var(--gutter-size);
+      min-height: 100%;
+      padding: var(--spacing-xs) var(--spacing-2xs);
+      position: absolute;
+      top: 0;
+      left: 0;
+      pointer-events: none;
+      user-select: none;
+      background: var(--color-bg);
+      border-right: fun.convert-px(1) solid var(--color-border);
+    }
+
+    .token {
+      &.comment,
+      &.doc-comment {
+        color: var(--color-fg-light);
+      }
+
+      &.punctuation {
+        color: var(--color-fg);
+      }
+
+      &.attr-name,
+      &.hexcode,
+      &.inserted,
+      &.string {
+        color: var(--color-token-green);
+      }
+
+      &.class,
+      &.coord,
+      &.id,
+      &.function {
+        color: var(--color-token-purple);
+      }
+
+      &.builtin,
+      &.builtin.class-name,
+      &.property-access,
+      &.regex,
+      &.scope {
+        color: var(--color-token-magenta);
+      }
+
+      &.class-name,
+      &.constant,
+      &.global,
+      &.interpolation,
+      &.key,
+      &.package,
+      &.this,
+      &.title,
+      &.variable {
+        color: var(--color-token-blue);
+      }
+
+      &.combinator,
+      &.keyword,
+      &.operator,
+      &.pseudo-class,
+      &.pseudo-element,
+      &.rule,
+      &.selector,
+      &.unit {
+        color: var(--color-token-orange);
+      }
+
+      &.attr-value,
+      &.boolean,
+      &.number {
+        color: var(--color-token-yellow);
+      }
+
+      &.delimiter,
+      &.doctype,
+      &.parameter,
+      &.parent,
+      &.property,
+      &.shebang,
+      &.tag {
+        color: var(--color-token-cyan);
+      }
+
+      &.deleted {
+        color: var(--color-token-red);
+      }
+
+      &.punctuation.brace-hover,
+      &.punctuation.brace-selected {
+        background: var(--color-bg);
+        outline: solid fun.convert-px(1) var(--color-primary-light);
+      }
+    }
+
+    span.inline-color-wrapper {
+      background: url(fun.encode-svg(
+        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"><path fill="gray" d="M0 0h2v2H0z"/><path fill="white" d="M0 0h1v1H0zM1 1h1v1H1z"/></svg>'
+      ));
+
+      /* Prevent glitches where 1px from the repeating pattern could be seen. */
+      background-position: center;
+      background-size: 110%;
+
+      display: inline-block;
+      height: 1.1ch;
+      width: 1.1ch;
+      margin: 0 0.5ch 0 0;
+      border: fun.convert-px(1) solid var(--color-bg);
+      outline: fun.convert-px(1) solid var(--color-border-dark);
+      overflow: hidden;
+    }
+
+    span.inline-color {
+      display: block;
+
+      /* To prevent visual glitches again */
+      height: 120%;
+      width: 120%;
+    }
+  }
+
+  pre.line-numbers {
+    counter-reset: lineNumber;
+
+    .line-numbers-rows {
+      > span {
+        counter-increment: lineNumber;
+
+        &::before {
+          display: block;
+          padding: 0 var(--spacing-xs);
+          content: counter(lineNumber);
+          color: var(--color-primary-darker);
+          text-align: right;
+          line-height: var(--line-height);
+        }
+      }
+    }
+  }
+
+  pre.command-line {
+    --gutter-size: clamp(#{fun.convert-px(195)}, 48vw, #{fun.convert-px(235)});
+
+    ~ .toolbar {
+      --gutter-size: clamp(
+        #{fun.convert-px(195)},
+        48vw,
+        #{fun.convert-px(235)}
+      );
+    }
+
+    .command-line-prompt {
+      > span {
+        &::before {
+          display: block;
+          content: "";
+        }
+
+        &[data-user]::before {
+          content: "[" attr(data-user) "@" attr(data-host) "] $";
+        }
+
+        &[data-user="root"]::before {
+          content: "[" attr(data-user) "@" attr(data-host) "] #";
+        }
+
+        &[data-prompt]::before {
+          content: attr(data-prompt);
+        }
+      }
+    }
+  }
+
+  .copy-to-clipboard-button,
+  .prism-color-scheme-button {
+    display: block;
+    padding: fun.convert-px(3) var(--spacing-xs);
+    background: var(--color-bg);
+    border: 0.4ex solid var(--color-primary);
+    border-radius: fun.convert-px(30);
+    box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+        var(--color-shadow),
+      fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2)
+        var(--color-shadow),
+      fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4)
+        var(--color-shadow);
+    color: var(--color-primary);
+    font-size: var(--font-size-sm);
+    font-weight: 600;
+    transition: all 0.35s ease-in-out 0s;
+
+    &:hover,
+    &:focus {
+      transform: translateX(#{fun.convert-px(-2)})
+        translateY(#{fun.convert-px(-2)});
+      box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1)
+          var(--color-shadow-light),
+        fun.convert-px(1) fun.convert-px(2) fun.convert-px(2) fun.convert-px(-2)
+          var(--color-shadow-light),
+        fun.convert-px(3) fun.convert-px(4) fun.convert-px(5) fun.convert-px(-4)
+          var(--color-shadow-light),
+        fun.convert-px(4) fun.convert-px(7) fun.convert-px(8) fun.convert-px(-3)
+          var(--color-shadow-light);
+    }
+
+    &:focus {
+      text-decoration: underline var(--color-primary) fun.convert-px(3);
+    }
+
+    &:active {
+      text-decoration: none;
+      transform: translateY(#{fun.convert-px(2)});
+      box-shadow: 0 0 0 0 var(--color-shadow);
+    }
+  }
+}
diff --git a/src/styles/pages/partials/_article-wp-blocks.scss b/src/styles/pages/partials/_article-wp-blocks.scss
new file mode 100644
index 0000000..d4fed5a
--- /dev/null
+++ b/src/styles/pages/partials/_article-wp-blocks.scss
@@ -0,0 +1,168 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+@mixin styles {
+  .wp-block-quote {
+    margin: var(--spacing-sm) 0;
+    padding: var(--spacing-sm);
+    position: relative;
+    border: fun.convert-px(1) solid var(--color-primary-lighter);
+    border-left: fun.convert-px(5) solid var(--color-primary-lighter);
+    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+        var(--color-shadow-light),
+      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+        var(--color-shadow-light);
+    font-style: italic;
+
+    > *:last-child {
+      margin: 0;
+    }
+
+    cite {
+      font-size: var(--font-size-sm);
+      font-style: normal;
+      font-weight: 600;
+    }
+  }
+
+  .wp-block-code,
+  .wp-block-preformatted {
+    margin: 0 auto var(--spacing-md);
+    padding: var(--spacing-xs) var(--spacing-sm);
+    background: var(--color-bg-secondary);
+    border: fun.convert-px(1) solid var(--color-border-light);
+    color: var(--color-fg);
+  }
+
+  .wp-block-columns {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr);
+    gap: var(--spacing-md);
+    margin: var(--spacing-md) 0;
+
+    @include mix.media("screen") {
+      @include mix.dimensions("sm") {
+        grid-template-columns: repeat(2, minmax(0, 1fr));
+      }
+    }
+
+    &.are-vertically-aligned-center {
+      align-items: center;
+    }
+  }
+
+  .wp-block-column {
+    > *:first-child {
+      margin-top: 0;
+    }
+
+    > *:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .wp-block-gallery {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr);
+    gap: var(--spacing-sm);
+
+    .blocks-gallery-grid {
+      @extend %reset-list;
+
+      grid-column: 1 / -1;
+      grid-row: 1 / -1;
+      display: grid;
+      grid-template-columns: minmax(0, 1fr);
+      gap: var(--spacing-sm);
+    }
+
+    .blocks-gallery-item {
+      figure {
+        margin: 0;
+      }
+
+      a {
+        display: block;
+        box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+          fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+            var(--color-shadow-light),
+          fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+            var(--color-shadow-light);
+
+        &:hover,
+        &:focus {
+          transform: scale(1.05);
+          box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+            fun.convert-px(3) fun.convert-px(3) fun.convert-px(2) 0
+              var(--color-shadow-light),
+            fun.convert-px(5) fun.convert-px(5) fun.convert-px(8) 0
+              var(--color-shadow-light);
+        }
+
+        &:focus {
+          outline: solid var(--color-primary-light);
+        }
+
+        &:active {
+          transform: scale(0.95);
+          box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+            fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+              var(--color-shadow-light),
+            0 0 0 0 var(--color-shadow-light);
+          outline: none;
+        }
+      }
+    }
+
+    &.aligncenter {
+      .blocks-gallery-grid {
+        align-items: center;
+      }
+    }
+
+    @for $i from 0 to 6 {
+      &.columns-#{$i} {
+        @include mix.media("screen") {
+          @include mix.dimensions("xs") {
+            grid-template-columns: repeat(2, minmax(0, 1fr));
+
+            .blocks-gallery-grid {
+              grid-template-columns: repeat(2, minmax(0, 1fr));
+            }
+          }
+
+          @include mix.dimensions("sm") {
+            grid-template-columns: repeat(#{$i}, minmax(0, 1fr));
+
+            .blocks-gallery-grid {
+              grid-template-columns: repeat(3, minmax(0, 1fr));
+            }
+          }
+        }
+      }
+    }
+  }
+
+  .wp-block-image {
+    img {
+      display: block;
+      margin: auto;
+      box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+        fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+          var(--color-shadow-light),
+        fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+          var(--color-shadow-light);
+      text-align: center;
+    }
+  }
+
+  .wp-block-video {
+    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
+      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
+        var(--color-shadow-light),
+      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
+        var(--color-shadow-light);
+  }
+}
diff --git a/src/styles/pages/topic.module.scss b/src/styles/pages/topic.module.scss
new file mode 100644
index 0000000..badb694
--- /dev/null
+++ b/src/styles/pages/topic.module.scss
@@ -0,0 +1,6 @@
+@use "@styles/abstracts/functions" as fun;
+
+.logo {
+  max-width: fun.convert-px(50);
+  margin-right: var(--spacing-xs);
+}
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index f354118..a3b9889 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -81,6 +81,7 @@ export type Page<T extends PageKind> = {
 
 export type PageLink = {
   id: number;
+  logo?: Image;
   name: string;
   slug: string;
 };
diff --git a/src/ts/types/raw-data.ts b/src/ts/types/raw-data.ts
index 7e12e7f..ba6d596 100644
--- a/src/ts/types/raw-data.ts
+++ b/src/ts/types/raw-data.ts
@@ -89,14 +89,17 @@ export type RawThematic = RawPage & {
 
 export type RawThematicPreview = Pick<
   RawThematic,
-  'databaseId' | 'slug' | 'title'
+  'databaseId' | 'featuredImage' | 'slug' | 'title'
 >;
 
 export type RawTopic = RawPage & {
   acfTopics: ACFTopics;
 };
 
-export type RawTopicPreview = Pick<RawTopic, 'databaseId' | 'slug' | 'title'>;
+export type RawTopicPreview = Pick<
+  RawTopic,
+  'databaseId' | 'featuredImage' | 'slug' | 'title'
+>;
 
 export type TotalItems = {
   pageInfo: Pick<PageInfo, 'total'>;
diff --git a/src/utils/helpers/pages.ts b/src/utils/helpers/pages.ts
index 93582f0..62337db 100644
--- a/src/utils/helpers/pages.ts
+++ b/src/utils/helpers/pages.ts
@@ -5,12 +5,14 @@ import {
   type RawThematicPreview,
   type RawTopicPreview,
 } from '@ts/types/raw-data';
+import { getImageFromRawData } from './images';
 
 /**
  * Convert raw data to a Link object.
  *
  * @param data - An object.
  * @param {number} data.databaseId - The data id.
+ * @param {number} [data.logo] - The data logo.
  * @param {string} data.slug - The data slug.
  * @param {string} data.title - The data name.
  * @returns {PageLink} The link data (id, slug and title).
@@ -18,10 +20,11 @@ import {
 export const getPageLinkFromRawData = (
   data: RawThematicPreview | RawTopicPreview
 ): PageLink => {
-  const { databaseId, slug, title } = data;
+  const { databaseId, featuredImage, slug, title } = data;
 
   return {
     id: databaseId,
+    logo: featuredImage ? getImageFromRawData(featuredImage?.node) : undefined,
     name: title,
     slug,
   };
diff --git a/src/utils/hooks/use-add-prism-class-attr.tsx b/src/utils/hooks/use-add-prism-class-attr.tsx
new file mode 100644
index 0000000..7d33cc2
--- /dev/null
+++ b/src/utils/hooks/use-add-prism-class-attr.tsx
@@ -0,0 +1,60 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export type AttributesMap = {
+  [key: string]: string;
+};
+
+export type useAddPrismClassAttrProps = {
+  attributes?: AttributesMap;
+  classNames?: string;
+};
+
+/**
+ * Add classnames and/or attributes to pre elements.
+ *
+ * @param props - An object of attributes and classnames.
+ */
+const useAddPrismClassAttr = ({
+  attributes,
+  classNames,
+}: useAddPrismClassAttrProps) => {
+  const [elements, setElements] = useState<HTMLPreElement[]>([]);
+
+  useEffect(() => {
+    const targetElements = document.querySelectorAll('pre');
+    setElements(Array.from(targetElements));
+  }, []);
+
+  const setClassNameAndAttributes = useCallback(
+    (array: HTMLElement[]) => {
+      array.forEach((el) => {
+        if (classNames) {
+          const classNamesArray = classNames.split(' ');
+          const isCommandLine = el.classList.contains('command-line');
+          const removedClassName = isCommandLine
+            ? 'line-numbers'
+            : 'command-line';
+          const filteredClassNames = classNamesArray.filter(
+            (className) => className !== removedClassName
+          );
+          filteredClassNames.forEach((className) =>
+            el.classList.add(className)
+          );
+        }
+
+        if (attributes) {
+          for (const [key, value] of Object.entries(attributes)) {
+            el.setAttribute(key, value);
+          }
+        }
+      });
+    },
+    [attributes, classNames]
+  );
+
+  useEffect(() => {
+    if (elements.length > 0) setClassNameAndAttributes(elements);
+  }, [elements, setClassNameAndAttributes]);
+};
+
+export default useAddPrismClassAttr;
-- 
cgit v1.2.3


From 7d9f874364ec6e255e62eb3027011bfed267904c Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 17 May 2022 18:37:38 +0200
Subject: chore: adjust articles styles

* change animation on article card hover
* change comments section alignment
---
 src/components/atoms/buttons/buttons.module.scss   |  2 +-
 src/components/atoms/headings/heading.module.scss  | 16 +++-
 src/components/atoms/headings/heading.stories.tsx  | 26 +++++++
 src/components/atoms/headings/heading.tsx          | 58 ++++++++++++---
 .../molecules/buttons/heading-button.module.scss   | 15 ++--
 .../molecules/buttons/heading-button.stories.tsx   | 73 ++++++-------------
 src/components/organisms/forms/comment-form.tsx    | 14 +++-
 .../organisms/layout/summary.module.scss           |  7 +-
 .../templates/page/page-layout.module.scss         |  9 ++-
 src/components/templates/page/page-layout.tsx      | 85 ++++++++++++++--------
 src/pages/article/[slug].tsx                       | 18 ++++-
 src/styles/pages/article.module.scss               |  1 +
 12 files changed, 211 insertions(+), 113 deletions(-)

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/components/atoms/buttons/buttons.module.scss b/src/components/atoms/buttons/buttons.module.scss
index 36c66b6..2444bb1 100644
--- a/src/components/atoms/buttons/buttons.module.scss
+++ b/src/components/atoms/buttons/buttons.module.scss
@@ -16,7 +16,7 @@
   }
 
   &--rectangle {
-    padding: var(--spacing-2xs) var(--spacing-md);
+    padding: var(--spacing-2xs) var(--spacing-sm);
   }
 
   &--square,
diff --git a/src/components/atoms/headings/heading.module.scss b/src/components/atoms/headings/heading.module.scss
index 8620f6f..a420bc1 100644
--- a/src/components/atoms/headings/heading.module.scss
+++ b/src/components/atoms/headings/heading.module.scss
@@ -6,11 +6,23 @@
   letter-spacing: 0.01ex;
 
   &--regular {
-    margin: 0;
+    margin-bottom: 0;
+    margin-top: 0;
+  }
+
+  &--left {
+    text-align: left;
+  }
+
+  &--center {
+    width: fit-content;
+    margin-left: auto;
+    margin-right: auto;
   }
 
   &--margin {
-    margin: 0 0 var(--spacing-sm);
+    margin-top: 0;
+    margin-bottom: var(--spacing-sm);
 
     & + & {
       margin-top: var(--spacing-md);
diff --git a/src/components/atoms/headings/heading.stories.tsx b/src/components/atoms/headings/heading.stories.tsx
index da5a718..0e3885d 100644
--- a/src/components/atoms/headings/heading.stories.tsx
+++ b/src/components/atoms/headings/heading.stories.tsx
@@ -8,10 +8,26 @@ export default {
   title: 'Atoms/Typography/Headings',
   component: Heading,
   args: {
+    alignment: 'left',
     isFake: false,
     withMargin: true,
   },
   argTypes: {
+    alignment: {
+      control: {
+        type: 'select',
+      },
+      description: 'The title alignment.',
+      options: ['center', 'left'],
+      table: {
+        category: 'Options',
+        defaultValue: { summary: 'left' },
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     className: {
       control: {
         type: 'text',
@@ -32,6 +48,16 @@ export default {
         required: true,
       },
     },
+    id: {
+      control: {
+        type: 'text',
+      },
+      description: 'An unique id.',
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     isFake: {
       control: {
         type: 'boolean',
diff --git a/src/components/atoms/headings/heading.tsx b/src/components/atoms/headings/heading.tsx
index c5bf4ca..e385249 100644
--- a/src/components/atoms/headings/heading.tsx
+++ b/src/components/atoms/headings/heading.tsx
@@ -1,9 +1,19 @@
-import { FC, ReactNode } from 'react';
+import {
+  createElement,
+  ForwardedRef,
+  forwardRef,
+  ForwardRefRenderFunction,
+  ReactNode,
+} from 'react';
 import styles from './heading.module.scss';
 
 export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
 
 export type HeadingProps = {
+  /**
+   * The title alignment. Default: left;
+   */
+  alignment?: 'center' | 'left';
   /**
    * The heading body.
    */
@@ -30,31 +40,55 @@ export type HeadingProps = {
   withMargin?: boolean;
 };
 
+type TitleTagProps = Pick<HeadingProps, 'children' | 'className' | 'id'> & {
+  tagName: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p';
+};
+
+const TitleTag = forwardRef<
+  HTMLHeadingElement | HTMLParagraphElement,
+  TitleTagProps
+>(
+  (
+    { children, tagName, ...props },
+    ref: ForwardedRef<HTMLHeadingElement | HTMLParagraphElement>
+  ) => {
+    return createElement(tagName, { ...props, ref }, children);
+  }
+);
+TitleTag.displayName = 'TitleTag';
+
 /**
  * Heading component.
  *
  * Render an HTML heading element or a paragraph with heading styles.
  */
-const Heading: FC<HeadingProps> = ({
-  children,
-  className,
-  id,
-  isFake = false,
-  level,
-  withMargin = true,
-}) => {
-  const TitleTag = isFake ? `p` : (`h${level}` as keyof JSX.IntrinsicElements);
+const Heading: ForwardRefRenderFunction<HTMLDivElement, HeadingProps> = (
+  {
+    alignment = 'left',
+    children,
+    className,
+    id,
+    isFake = false,
+    level,
+    withMargin = true,
+  },
+  ref: ForwardedRef<HTMLHeadingElement | HTMLParagraphElement>
+) => {
+  const tagName = isFake ? 'p' : (`h${level}` as TitleTagProps['tagName']);
   const levelClass = `heading--${level}`;
+  const alignmentClass = `heading--${alignment}`;
   const marginClass = withMargin ? 'heading--margin' : 'heading--regular';
 
   return (
     <TitleTag
-      className={`${styles.heading} ${styles[levelClass]} ${styles[marginClass]} ${className}`}
+      tagName={tagName}
+      className={`${styles.heading} ${styles[levelClass]} ${styles[alignmentClass]} ${styles[marginClass]} ${className}`}
       id={id}
+      ref={ref}
     >
       {children}
     </TitleTag>
   );
 };
 
-export default Heading;
+export default forwardRef(Heading);
diff --git a/src/components/molecules/buttons/heading-button.module.scss b/src/components/molecules/buttons/heading-button.module.scss
index 9c278e4..3c69221 100644
--- a/src/components/molecules/buttons/heading-button.module.scss
+++ b/src/components/molecules/buttons/heading-button.module.scss
@@ -20,6 +20,14 @@
   border-bottom: fun.convert-px(2) solid var(--color-primary-dark);
   cursor: pointer;
 
+  .heading {
+    padding: var(--spacing-2xs) 0;
+    background: none;
+    font-size: var(--font-size-xl);
+    font-weight: 500;
+    text-align: left;
+  }
+
   &:hover,
   &:focus {
     .icon {
@@ -34,10 +42,3 @@
     }
   }
 }
-
-.heading {
-  padding: var(--spacing-2xs) 0;
-  background: none;
-  font-size: var(--font-size-xl);
-  text-align: left;
-}
diff --git a/src/components/molecules/buttons/heading-button.stories.tsx b/src/components/molecules/buttons/heading-button.stories.tsx
index b0e1465..d1b5ed4 100644
--- a/src/components/molecules/buttons/heading-button.stories.tsx
+++ b/src/components/molecules/buttons/heading-button.stories.tsx
@@ -1,6 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import { useState } from 'react';
-import { IntlProvider } from 'react-intl';
 import HeadingButtonComponent from './heading-button';
 
 /**
@@ -10,6 +9,19 @@ export default {
   title: 'Molecules/Buttons/HeadingButton',
   component: HeadingButtonComponent,
   argTypes: {
+    className: {
+      control: {
+        type: 'text',
+      },
+      description: 'Set additional classnames to the button.',
+      table: {
+        category: 'Styles',
+      },
+      type: {
+        name: 'string',
+        required: false,
+      },
+    },
     expanded: {
       control: {
         type: null,
@@ -53,13 +65,6 @@ export default {
       },
     },
   },
-  decorators: [
-    (Story) => (
-      <IntlProvider locale="en">
-        <Story />
-      </IntlProvider>
-    ),
-  ],
 } as ComponentMeta<typeof HeadingButtonComponent>;
 
 const Template: ComponentStory<typeof HeadingButtonComponent> = ({
@@ -79,55 +84,21 @@ const Template: ComponentStory<typeof HeadingButtonComponent> = ({
 };
 
 /**
- * Heading Button Stories - Level 1
+ * Heading Button Stories - Expanded
  */
-export const Level1 = Template.bind({});
-Level1.args = {
-  level: 1,
-  title: 'Your title',
-};
-
-/**
- * Heading Button Stories - Level 2
- */
-export const Level2 = Template.bind({});
-Level2.args = {
+export const Expanded = Template.bind({});
+Expanded.args = {
+  expanded: true,
   level: 2,
   title: 'Your title',
 };
 
 /**
- * Heading Button Stories - Level 3
+ * Heading Button Stories - Collapsed
  */
-export const Level3 = Template.bind({});
-Level3.args = {
-  level: 3,
-  title: 'Your title',
-};
-
-/**
- * Heading Button Stories - Level 4
- */
-export const Level4 = Template.bind({});
-Level4.args = {
-  level: 4,
-  title: 'Your title',
-};
-
-/**
- * Heading Button Stories - Level 5
- */
-export const Level5 = Template.bind({});
-Level5.args = {
-  level: 5,
-  title: 'Your title',
-};
-
-/**
- * Heading Button Stories - Level 6
- */
-export const Level6 = Template.bind({});
-Level6.args = {
-  level: 6,
+export const Collapsed = Template.bind({});
+Collapsed.args = {
+  expanded: false,
+  level: 2,
   title: 'Your title',
 };
diff --git a/src/components/organisms/forms/comment-form.tsx b/src/components/organisms/forms/comment-form.tsx
index 5ff4ea4..b2c725f 100644
--- a/src/components/organisms/forms/comment-form.tsx
+++ b/src/components/organisms/forms/comment-form.tsx
@@ -1,6 +1,9 @@
 import Button from '@components/atoms/buttons/button';
 import Form, { type FormProps } from '@components/atoms/forms/form';
-import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import Heading, {
+  type HeadingProps,
+  type HeadingLevel,
+} from '@components/atoms/headings/heading';
 import Spinner from '@components/atoms/loaders/spinner';
 import LabelledField from '@components/molecules/forms/labelled-field';
 import { FC, ReactNode, useState } from 'react';
@@ -34,7 +37,11 @@ export type CommentFormProps = Pick<FormProps, 'className'> & {
    */
   title?: string;
   /**
-   * The title level.
+   * The form title alignment. Default: left.
+   */
+  titleAlignment?: HeadingProps['alignment'];
+  /**
+   * The title level. Default: 2.
    */
   titleLevel?: HeadingLevel;
 };
@@ -44,6 +51,7 @@ const CommentForm: FC<CommentFormProps> = ({
   parentId,
   saveComment,
   title,
+  titleAlignment,
   titleLevel = 2,
   ...props
 }) => {
@@ -117,7 +125,7 @@ const CommentForm: FC<CommentFormProps> = ({
       {...props}
     >
       {title && (
-        <Heading id={formId} level={titleLevel}>
+        <Heading id={formId} level={titleLevel} alignment={titleAlignment}>
           {title}
         </Heading>
       )}
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
index 7e86dd2..62dfc0e 100644
--- a/src/components/organisms/layout/summary.module.scss
+++ b/src/components/organisms/layout/summary.module.scss
@@ -29,8 +29,11 @@
 
   &:hover {
     .icon {
-      transform: scaleX(1.4);
-      transform-origin: left;
+      --icon-size: #{fun.convert-px(35)};
+
+      :global {
+        animation: pulse 1.5s ease-in-out 0.2s infinite;
+      }
     }
   }
 }
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
index 83e5a80..c6b4e8d 100644
--- a/src/components/templates/page/page-layout.module.scss
+++ b/src/components/templates/page/page-layout.module.scss
@@ -86,7 +86,14 @@
 
   &__section {
     grid-column: 2;
-    margin: var(--spacing-md) 0 0;
+
+    &:first-child {
+      margin: var(--spacing-md) 0 0;
+    }
+  }
+
+  &__no-comments {
+    text-align: center;
   }
 
   &__form {
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index f3f3ea8..2ff2084 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -1,6 +1,7 @@
 import Heading from '@components/atoms/headings/heading';
 import Notice, { type NoticeKind } from '@components/atoms/layout/notice';
 import Sidebar from '@components/atoms/layout/sidebar';
+import { MetaData } from '@components/molecules/layout/meta';
 import PageFooter, {
   type PageFooterProps,
 } from '@components/molecules/layout/page-footer';
@@ -118,10 +119,10 @@ const PageLayout: FC<PageLayoutProps> = ({
   const isMounted = useIsMounted(bodyRef);
 
   const hasComments = Array.isArray(comments) && comments.length > 0;
-  const hasCommentsSection = hasComments || allowComments;
-  const articleModifier = hasCommentsSection
-    ? 'article--has-comments'
-    : 'article--no-comments';
+  const articleModifier =
+    hasComments || allowComments
+      ? 'article--has-comments'
+      : 'article--no-comments';
 
   const [status, setStatus] = useState<NoticeKind>('info');
   const [statusMessage, setStatusMessage] = useState<string>('');
@@ -175,6 +176,15 @@ const PageLayout: FC<PageLayoutProps> = ({
     }
   };
 
+  /**
+   * Check if meta properties are defined.
+   *
+   * @param {MetaData} meta - The metadata.
+   */
+  const hasMeta = (meta: MetaData) => {
+    return Object.values(meta).every((value) => value === null);
+  };
+
   return (
     <Layout
       breadcrumbSchema={breadcrumbSchema}
@@ -218,7 +228,9 @@ const PageLayout: FC<PageLayoutProps> = ({
           {children}
         </div>
       )}
-      <PageFooter meta={footerMeta} className={styles.footer} />
+      {footerMeta && hasMeta(footerMeta) && (
+        <PageFooter meta={footerMeta} className={styles.footer} />
+      )}
       <Sidebar
         className={`${styles.sidebar} ${styles['sidebar--last']}`}
         aria-label={intl.formatMessage({
@@ -229,45 +241,54 @@ const PageLayout: FC<PageLayoutProps> = ({
       >
         {widgets}
       </Sidebar>
-      {hasCommentsSection && (
-        <div className={styles.comments}>
-          {hasComments && (
-            <section className={styles.comments__section}>
-              <Heading level={2}>{commentsTitle}</Heading>
+      {allowComments && (
+        <div className={styles.comments} id="comments">
+          <section className={styles.comments__section}>
+            <Heading level={2} alignment="center">
+              {commentsTitle}
+            </Heading>
+            {hasComments ? (
               <CommentsList
                 comments={comments}
                 depth={1}
                 Notice={
-                  isReplyRef.current === true ? (
+                  isReplyRef.current === true && (
                     <Notice
                       kind={status}
                       message={statusMessage}
                       className={styles.notice}
                     />
-                  ) : undefined
+                  )
                 }
                 saveComment={saveComment}
               />
-            </section>
-          )}
-          {allowComments && (
-            <section className={styles.comments__section}>
-              <CommentForm
-                className={styles.comments__form}
-                saveComment={saveComment}
-                title={commentFormTitle}
-                Notice={
-                  isReplyRef.current === false ? (
-                    <Notice
-                      kind={status}
-                      message={statusMessage}
-                      className={styles.notice}
-                    />
-                  ) : undefined
-                }
-              />
-            </section>
-          )}
+            ) : (
+              <p className={styles['comments__no-comments']}>
+                {intl.formatMessage({
+                  defaultMessage: 'No comments.',
+                  id: 'sBwfCy',
+                  description: 'PageLayout: no comments text',
+                })}
+              </p>
+            )}
+          </section>
+          <section className={styles.comments__section}>
+            <CommentForm
+              className={styles.comments__form}
+              saveComment={saveComment}
+              title={commentFormTitle}
+              titleAlignment="center"
+              Notice={
+                isReplyRef.current === false && (
+                  <Notice
+                    kind={status}
+                    message={statusMessage}
+                    className={styles.notice}
+                  />
+                )
+              }
+            />
+          </section>
         </div>
       )}
     </Layout>
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 995e3a9..a0fb7fc 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -15,7 +15,10 @@ import { type Article, type Comment } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import useAddPrismClassAttr from '@utils/hooks/use-add-prism-class-attr';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
-import usePrismPlugins, { PrismPlugin } from '@utils/hooks/use-prism-plugins';
+import usePrismPlugins, {
+  type PrismPlugin,
+} from '@utils/hooks/use-prism-plugins';
+import useReadingTime from '@utils/hooks/use-reading-time';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
 import Head from 'next/head';
@@ -38,7 +41,16 @@ type ArticlePageProps = {
  */
 const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
   const { content, id, intro, meta, slug, title } = post;
-  const { author, commentsCount, cover, dates, seo, thematics, topics } = meta;
+  const {
+    author,
+    commentsCount,
+    cover,
+    dates,
+    seo,
+    thematics,
+    topics,
+    wordsCount,
+  } = meta;
   const { data } = useSWR(() => id, getPostComments, {
     fallbackData: comments,
   });
@@ -46,11 +58,13 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
     title,
     url: `/article/${slug}`,
   });
+  const readingTime = useReadingTime(wordsCount || 0, true);
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     author: author?.name,
     publication: { date: dates.publication },
     update: dates.update ? { date: dates.update } : undefined,
+    readingTime,
     thematics:
       thematics &&
       thematics.map((thematic) => (
diff --git a/src/styles/pages/article.module.scss b/src/styles/pages/article.module.scss
index a42c633..04acad9 100644
--- a/src/styles/pages/article.module.scss
+++ b/src/styles/pages/article.module.scss
@@ -31,6 +31,7 @@
   @include mix.media("screen") {
     @include mix.dimensions("md") {
       width: min-content;
+      gap: var(--spacing-2xs);
     }
   }
 }
-- 
cgit v1.2.3


From 271ef6debaca7ed9a01829dcef3a37e90a2dff05 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 17 May 2022 22:48:41 +0200
Subject: chore: use persistent layout

It prevents to rerender the common components between pages (header,
footer...).
---
 src/components/templates/layout/layout.module.scss | 14 ++++++
 src/components/templates/layout/layout.test.tsx    | 11 ++---
 src/components/templates/layout/layout.tsx         | 54 +++++++++++++---------
 .../templates/page/page-layout.module.scss         | 11 -----
 .../templates/page/page-layout.stories.tsx         | 24 ++++------
 src/components/templates/page/page-layout.tsx      | 35 +++++++-------
 .../sectioned/sectioned-layout.stories.tsx         |  8 ++++
 .../templates/sectioned/sectioned-layout.tsx       | 25 ++++++++--
 src/pages/404.tsx                                  |  9 +++-
 src/pages/_app.tsx                                 |  7 +--
 src/pages/article/[slug].tsx                       | 16 +++++--
 src/pages/blog/index.tsx                           | 14 ++++--
 src/pages/contact.tsx                              | 11 +++--
 src/pages/cv.tsx                                   |  9 +++-
 src/pages/index.tsx                                | 20 +++++---
 src/pages/mentions-legales.tsx                     |  9 +++-
 src/pages/projets/[slug].tsx                       | 14 ++++--
 src/pages/projets/index.tsx                        | 10 ++--
 src/pages/recherche/index.tsx                      | 14 ++++--
 src/pages/sujet/[slug].tsx                         | 18 ++++++--
 src/pages/thematique/[slug].tsx                    | 15 ++++--
 src/ts/types/app.ts                                | 21 +++++++++
 22 files changed, 253 insertions(+), 116 deletions(-)

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index 09904dc..1080732 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -1,5 +1,6 @@
 @use "@styles/abstracts/functions" as fun;
 @use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
 
 .header {
   border-bottom: fun.convert-px(3) solid var(--color-border-light);
@@ -9,6 +10,19 @@
   flex: 1;
 }
 
+.article {
+  &--grid {
+    @extend %grid;
+
+    grid-auto-flow: column dense;
+    align-items: baseline;
+  }
+
+  &--padding {
+    padding-bottom: var(--spacing-lg);
+  }
+}
+
 .footer {
   border-top: fun.convert-px(3) solid var(--color-border-light);
 }
diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx
index 94145ec..78547d4 100644
--- a/src/components/templates/layout/layout.test.tsx
+++ b/src/components/templates/layout/layout.test.tsx
@@ -4,33 +4,32 @@ import Layout from './layout';
 
 const body =
   'Sit dolorem eveniet. Sit sit odio nemo vitae corrupti modi sint est rerum. Pariatur quidem maiores distinctio. Quia et illum aspernatur est cum.';
-const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
 
 describe('Layout', () => {
   it('renders the website header', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('banner')).toBeInTheDocument();
   });
 
   it('renders the website main content', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('main')).toBeInTheDocument();
   });
 
   it('renders the website footer', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('contentinfo')).toBeInTheDocument();
   });
 
   it('renders a skip to content link', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(
       screen.getByRole('link', { name: 'Skip to content' })
     ).toBeInTheDocument();
   });
 
   it('renders an article', () => {
-    render(<Layout breadcrumbSchema={breadcrumbSchema}>{body}</Layout>);
+    render(<Layout>{body}</Layout>);
     expect(screen.getByRole('article')).toHaveTextContent(body);
   });
 });
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 3eb02ee..559eaed 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -9,18 +9,13 @@ import Main from '@components/atoms/layout/main';
 import NoScript from '@components/atoms/layout/no-script';
 import Footer, { type FooterProps } from '@components/organisms/layout/footer';
 import Header, { type HeaderProps } from '@components/organisms/layout/header';
+import { type NextPageWithLayoutOptions } from '@ts/types/app';
 import useScrollPosition from '@utils/hooks/use-scroll-position';
 import useSettings from '@utils/hooks/use-settings';
 import Script from 'next/script';
-import { FC, ReactNode, useState } from 'react';
+import { FC, ReactElement, ReactNode, useState } from 'react';
 import { useIntl } from 'react-intl';
-import {
-  BreadcrumbList,
-  Person,
-  SearchAction,
-  WebSite,
-  WithContext,
-} from 'schema-dts';
+import { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
 import styles from './layout.module.scss';
 
 export type QueryAction = SearchAction & {
@@ -28,18 +23,18 @@ export type QueryAction = SearchAction & {
 };
 
 export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
-  /**
-   * The breadcrumb JSON schema.
-   */
-  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * The layout main content.
    */
   children: ReactNode;
   /**
-   * Set additional classnames to the article element.
+   * Determine if article has a comments section.
+   */
+  withExtraPadding?: boolean;
+  /**
+   * Determine if article should use grid. Default: false.
    */
-  className?: string;
+  useGrid?: boolean;
 };
 
 /**
@@ -48,14 +43,16 @@ export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
  * Render the base layout used by all pages.
  */
 const Layout: FC<LayoutProps> = ({
-  breadcrumbSchema,
   children,
+  withExtraPadding = false,
   isHome,
-  ...props
+  useGrid = false,
 }) => {
   const intl = useIntl();
   const { website } = useSettings();
   const { baseline, copyright, locales, name, picture, url } = website;
+  const articleGridClass = useGrid ? 'article--grid' : '';
+  const articleCommentsClass = withExtraPadding ? 'article--padding' : '';
 
   const skipToContent = intl.formatMessage({
     defaultMessage: 'Skip to content',
@@ -188,11 +185,6 @@ const Layout: FC<LayoutProps> = ({
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
       />
-      <Script
-        id="schema-breadcrumb"
-        type="application/ld+json"
-        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
-      />
       <noscript>
         <div className={styles['noscript-spacing']}></div>
       </noscript>
@@ -211,7 +203,11 @@ const Layout: FC<LayoutProps> = ({
         className={styles.header}
       />
       <Main id="main" className={styles.main}>
-        <article {...props}>{children}</article>
+        <article
+          className={`${styles[articleGridClass]} ${styles[articleCommentsClass]}`}
+        >
+          {children}
+        </article>
       </Main>
       <Footer
         copyright={copyrightData}
@@ -227,4 +223,18 @@ const Layout: FC<LayoutProps> = ({
   );
 };
 
+/**
+ * Get the global layout.
+ *
+ * @param {ReactElement} page - A page.
+ * @param {boolean} [isHome] - Determine if it is the homepage.
+ * @returns A page wrapped with the global layout.
+ */
+export const getLayout = (
+  page: ReactElement,
+  props: NextPageWithLayoutOptions
+) => {
+  return <Layout {...props}>{page}</Layout>;
+};
+
 export default Layout;
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
index c6b4e8d..c7674ae 100644
--- a/src/components/templates/page/page-layout.module.scss
+++ b/src/components/templates/page/page-layout.module.scss
@@ -2,17 +2,6 @@
 @use "@styles/abstracts/mixins" as mix;
 @use "@styles/abstracts/placeholders";
 
-.article {
-  @extend %grid;
-
-  grid-auto-flow: column dense;
-  align-items: baseline;
-
-  &--no-comments {
-    padding-bottom: var(--spacing-lg);
-  }
-}
-
 .breadcrumb {
   @extend %grid;
 
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index 8af5f98..bec1066 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -1,11 +1,11 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading from '@components/atoms/headings/heading';
 import Link from '@components/atoms/links/link';
-import ProgressBar from '@components/atoms/loaders/progress-bar';
 import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import Sharing from '@components/organisms/widgets/sharing';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { LayoutBase } from '../layout/layout.stories';
 import PageLayoutComponent from './page-layout';
 
 /**
@@ -118,20 +118,6 @@ export default {
         required: false,
       },
     },
-    isHome: {
-      control: {
-        type: 'boolean',
-      },
-      description: 'Determine if the current page is the homepage.',
-      table: {
-        category: 'Options',
-        defaultValue: { summary: false },
-      },
-      type: {
-        name: 'boolean',
-        required: false,
-      },
-    },
     title: {
       control: {
         type: 'text',
@@ -168,6 +154,13 @@ export default {
       },
     },
   },
+  decorators: [
+    (Story) => (
+      <LayoutBase {...LayoutBase.args}>
+        <Story />
+      </LayoutBase>
+    ),
+  ],
   parameters: {
     layout: 'fullscreen',
   },
@@ -477,7 +470,6 @@ Blog.args = {
   children: (
     <>
       <PostsList posts={posts} byYear={true} total={posts.length} />
-      <ProgressBar min={1} max={1} current={1} info="1/1 page loaded." />
     </>
   ),
   widgets: [
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 2ff2084..d171944 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -21,19 +21,20 @@ import TableOfContents from '@components/organisms/widgets/table-of-contents';
 import { type SendCommentVars } from '@services/graphql/api';
 import { sendComment } from '@services/graphql/comments';
 import useIsMounted from '@utils/hooks/use-is-mounted';
+import Script from 'next/script';
 import { FC, HTMLAttributes, ReactNode, useRef, useState } from 'react';
 import { useIntl } from 'react-intl';
-import Layout, { type LayoutProps } from '../layout/layout';
+import { BreadcrumbList } from 'schema-dts';
 import styles from './page-layout.module.scss';
 
-export type PageLayoutProps = Pick<
-  LayoutProps,
-  'breadcrumbSchema' | 'isHome'
-> & {
+export type PageLayoutProps = {
   /**
    * True if the page accepts new comments. Default: false.
    */
   allowComments?: boolean;
+  /**
+   * Set attributes to the page body.
+   */
   bodyAttributes?: HTMLAttributes<HTMLDivElement>;
   /**
    * Set additional classnames to the body wrapper.
@@ -43,6 +44,10 @@ export type PageLayoutProps = Pick<
    * The breadcrumb items.
    */
   breadcrumb: BreadcrumbItem[];
+  /**
+   * The breadcrumb JSON schema.
+   */
+  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * The main content of the page.
    */
@@ -98,7 +103,6 @@ const PageLayout: FC<PageLayoutProps> = ({
   headerMeta,
   id,
   intro,
-  isHome = false,
   title,
   widgets,
   withToC = false,
@@ -117,13 +121,7 @@ const PageLayout: FC<PageLayoutProps> = ({
 
   const bodyRef = useRef<HTMLDivElement>(null);
   const isMounted = useIsMounted(bodyRef);
-
   const hasComments = Array.isArray(comments) && comments.length > 0;
-  const articleModifier =
-    hasComments || allowComments
-      ? 'article--has-comments'
-      : 'article--no-comments';
-
   const [status, setStatus] = useState<NoticeKind>('info');
   const [statusMessage, setStatusMessage] = useState<string>('');
   const isReplyRef = useRef<boolean>(false);
@@ -186,11 +184,12 @@ const PageLayout: FC<PageLayoutProps> = ({
   };
 
   return (
-    <Layout
-      breadcrumbSchema={breadcrumbSchema}
-      isHome={isHome}
-      className={`${styles.article} ${styles[articleModifier]}`}
-    >
+    <>
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
       <Breadcrumb
         items={breadcrumb}
         className={styles.breadcrumb}
@@ -291,7 +290,7 @@ const PageLayout: FC<PageLayoutProps> = ({
           </section>
         </div>
       )}
-    </Layout>
+    </>
   );
 };
 
diff --git a/src/components/templates/sectioned/sectioned-layout.stories.tsx b/src/components/templates/sectioned/sectioned-layout.stories.tsx
index ce31a83..689f9a7 100644
--- a/src/components/templates/sectioned/sectioned-layout.stories.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.stories.tsx
@@ -1,4 +1,5 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
+import { LayoutBase } from '../layout/layout.stories';
 import SectionedLayoutComponent from './sectioned-layout';
 
 /**
@@ -31,6 +32,13 @@ export default {
       },
     },
   },
+  decorators: [
+    (Story) => (
+      <LayoutBase {...LayoutBase.args}>
+        <Story />
+      </LayoutBase>
+    ),
+  ],
   parameters: {
     layout: 'fullscreen',
   },
diff --git a/src/components/templates/sectioned/sectioned-layout.tsx b/src/components/templates/sectioned/sectioned-layout.tsx
index 58d5ad0..f91c354 100644
--- a/src/components/templates/sectioned/sectioned-layout.tsx
+++ b/src/components/templates/sectioned/sectioned-layout.tsx
@@ -2,12 +2,17 @@ import Section, {
   type SectionProps,
   type SectionVariant,
 } from '@components/atoms/layout/section';
+import Script from 'next/script';
 import { FC } from 'react';
-import Layout, { type LayoutProps } from '../layout/layout';
+import { BreadcrumbList } from 'schema-dts';
 
 export type Section = Pick<SectionProps, 'content' | 'title'>;
 
-export type SectionedLayoutProps = Pick<LayoutProps, 'breadcrumbSchema'> & {
+export type SectionedLayoutProps = {
+  /**
+   * The breadcrumb JSON schema.
+   */
+  breadcrumbSchema: BreadcrumbList['itemListElement'][];
   /**
    * An array of objects describing each section.
    */
@@ -19,7 +24,10 @@ export type SectionedLayoutProps = Pick<LayoutProps, 'breadcrumbSchema'> & {
  *
  * Render a sectioned layout.
  */
-const SectionedLayout: FC<SectionedLayoutProps> = ({ sections, ...props }) => {
+const SectionedLayout: FC<SectionedLayoutProps> = ({
+  breadcrumbSchema,
+  sections,
+}) => {
   const getSections = (items: SectionProps[]) => {
     return items.map((section, index) => {
       const variant: SectionVariant = index % 2 ? 'light' : 'dark';
@@ -37,7 +45,16 @@ const SectionedLayout: FC<SectionedLayoutProps> = ({ sections, ...props }) => {
     });
   };
 
-  return <Layout {...props}>{getSections(sections)}</Layout>;
+  return (
+    <>
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
+      {getSections(sections)}
+    </>
+  );
 };
 
 export default SectionedLayout;
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 4f6e22d..4ab7784 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -1,11 +1,13 @@
 import Link from '@components/atoms/links/link';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
   type RawThematicPreview,
   type RawTopicPreview,
@@ -17,7 +19,7 @@ import {
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { ReactNode } from 'react';
 import { useIntl } from 'react-intl';
@@ -31,7 +33,7 @@ type Error404PageProps = {
 /**
  * Error 404 page.
  */
-const Error404Page: NextPage<Error404PageProps> = ({
+const Error404Page: NextPageWithLayout<Error404PageProps> = ({
   thematicsList,
   topicsList,
 }) => {
@@ -119,6 +121,9 @@ const Error404Page: NextPage<Error404PageProps> = ({
   );
 };
 
+Error404Page.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<Error404PageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 939b337..5bc9f85 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -1,15 +1,16 @@
+import { type AppPropsWithLayout } from '@ts/types/app';
 import { settings } from '@utils/config';
 import { AckeeProvider } from '@utils/providers/ackee';
 import { PrismThemeProvider } from '@utils/providers/prism-theme';
 import { ThemeProvider } from 'next-themes';
-import { AppProps } from 'next/app';
 import { useRouter } from 'next/router';
 import { IntlProvider } from 'react-intl';
 import '../styles/globals.scss';
 
-const App = ({ Component, pageProps }: AppProps) => {
+const App = ({ Component, pageProps }: AppPropsWithLayout) => {
   const { locale, defaultLocale } = useRouter();
   const appLocale: string = locale || settings.locales.defaultLocale;
+  const getLayout = Component.getLayout ?? ((page) => page);
 
   return (
     <AckeeProvider domain={settings.ackee.url} siteId={settings.ackee.siteId}>
@@ -24,7 +25,7 @@ const App = ({ Component, pageProps }: AppProps) => {
           enableSystem={true}
         >
           <PrismThemeProvider>
-            <Component {...pageProps} />
+            {getLayout(<Component {...pageProps} />, {})}
           </PrismThemeProvider>
         </ThemeProvider>
       </IntlProvider>
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index a0fb7fc..a3df43b 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -2,6 +2,7 @@ import ButtonLink from '@components/atoms/buttons/button-link';
 import Link from '@components/atoms/links/link';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Sharing from '@components/organisms/widgets/sharing';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
@@ -11,7 +12,11 @@ import {
 } from '@services/graphql/articles';
 import { getPostComments } from '@services/graphql/comments';
 import styles from '@styles/pages/article.module.scss';
-import { type Article, type Comment } from '@ts/types/app';
+import {
+  type Article,
+  type Comment,
+  type NextPageWithLayout,
+} from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import useAddPrismClassAttr from '@utils/hooks/use-add-prism-class-attr';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
@@ -20,7 +25,7 @@ import usePrismPlugins, {
 } from '@utils/hooks/use-prism-plugins';
 import useReadingTime from '@utils/hooks/use-reading-time';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -39,7 +44,10 @@ type ArticlePageProps = {
 /**
  * Article page.
  */
-const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
+const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
+  comments,
+  post,
+}) => {
   const { content, id, intro, meta, slug, title } = post;
   const {
     author,
@@ -252,6 +260,8 @@ const ArticlePage: NextPage<ArticlePageProps> = ({ comments, post }) => {
   );
 };
 
+ArticlePage.getLayout = (page) => getLayout(page, { useGrid: true });
+
 interface PostParams extends ParsedUrlQuery {
   slug: string;
 }
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index b6ce221..2676305 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,6 +1,7 @@
 import Notice from '@components/atoms/layout/notice';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
 import {
@@ -13,7 +14,11 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
-import { type Article, type Meta } from '@ts/types/app';
+import {
+  type Article,
+  type Meta,
+  type NextPageWithLayout,
+} from '@ts/types/app';
 import {
   RawThematicPreview,
   RawTopicPreview,
@@ -28,7 +33,7 @@ import {
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -46,7 +51,7 @@ type BlogPageProps = {
 /**
  * Blog index page.
  */
-const BlogPage: NextPage<BlogPageProps> = ({
+const BlogPage: NextPageWithLayout<BlogPageProps> = ({
   articles,
   thematicsList,
   topicsList,
@@ -268,6 +273,9 @@ const BlogPage: NextPage<BlogPageProps> = ({
   );
 };
 
+BlogPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<BlogPageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index 617117b..c0d6c79 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -3,22 +3,24 @@ import ContactForm, {
   type ContactFormProps,
 } from '@components/organisms/forms/contact-form';
 import SocialMedia from '@components/organisms/widgets/social-media';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { meta } from '@content/pages/contact.mdx';
 import styles from '@styles/pages/contact.module.scss';
 import { sendMail } from '@services/graphql/contact';
+import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
+import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { useState } from 'react';
 import { useIntl } from 'react-intl';
 import { ContactPage as ContactPageSchema, Graph, WebPage } from 'schema-dts';
-import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 
-const ContactPage: NextPage = () => {
+const ContactPage: NextPageWithLayout = () => {
   const { dates, intro, seo, title } = meta;
   const intl = useIntl();
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
@@ -164,6 +166,9 @@ const ContactPage: NextPage = () => {
   );
 };
 
+ContactPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps = async ({ locale }) => {
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index 7936c84..3f035d8 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -3,16 +3,18 @@ import Link from '@components/atoms/links/link';
 import List from '@components/atoms/lists/list';
 import ImageWidget from '@components/organisms/widgets/image-widget';
 import SocialMedia from '@components/organisms/widgets/social-media';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import CVContent, { data, meta } from '@content/pages/cv.mdx';
 import styles from '@styles/pages/cv.module.scss';
+import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -23,7 +25,7 @@ import { AboutPage, Graph, WebPage } from 'schema-dts';
 /**
  * CV page.
  */
-const CVPage: NextPage = () => {
+const CVPage: NextPageWithLayout = () => {
   const intl = useIntl();
   const { file, image } = data;
   const { dates, intro, seo, title } = meta;
@@ -186,6 +188,9 @@ const CVPage: NextPage = () => {
   );
 };
 
+CVPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps = async ({ locale }) => {
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index 1143a33..a831ea3 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -13,16 +13,16 @@ import Columns, {
 import CardsList, {
   type CardsListItem,
 } from '@components/organisms/layout/cards-list';
-import Layout from '@components/templates/layout/layout';
+import { getLayout } from '@components/templates/layout/layout';
 import HomePageContent from '@content/pages/homepage.mdx';
 import { getArticlesCard } from '@services/graphql/articles';
 import styles from '@styles/pages/home.module.scss';
-import { ArticleCard } from '@ts/types/app';
+import { type ArticleCard, type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import Script from 'next/script';
 import { ReactElement } from 'react';
@@ -37,7 +37,7 @@ type HomeProps = {
 /**
  * Home page.
  */
-const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
+const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
   const intl = useIntl();
   const { schema: breadcrumbSchema } = useBreadcrumb({
     title: '',
@@ -327,7 +327,7 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
   };
 
   return (
-    <Layout breadcrumbSchema={breadcrumbSchema} isHome={true}>
+    <>
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
@@ -340,11 +340,19 @@ const HomePage: NextPage<HomeProps> = ({ recentPosts }) => {
         type="application/ld+json"
         dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
       />
+      <Script
+        id="schema-breadcrumb"
+        type="application/ld+json"
+        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
+      />
       <HomePageContent components={components} />
-    </Layout>
+    </>
   );
 };
 
+HomePage.getLayout = (page) =>
+  getLayout(page, { isHome: true, withExtraPadding: false });
+
 export const getStaticProps: GetStaticProps<HomeProps> = async ({ locale }) => {
   const translation = await loadTranslation(locale);
   const recentPosts = await getArticlesCard({ first: 3 });
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index 41bc218..c8d1772 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -1,14 +1,16 @@
 import Link from '@components/atoms/links/link';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import LegalNoticeContent, { meta } from '@content/pages/legal-notice.mdx';
+import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -17,7 +19,7 @@ import { Article, Graph, WebPage } from 'schema-dts';
 /**
  * Legal Notice page.
  */
-const LegalNoticePage: NextPage = () => {
+const LegalNoticePage: NextPageWithLayout = () => {
   const { dates, intro, seo, title } = meta;
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
     title,
@@ -111,6 +113,9 @@ const LegalNoticePage: NextPage = () => {
   );
 };
 
+LegalNoticePage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps = async ({ locale }) => {
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 1a90e0f..cf7d0dc 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -6,10 +6,15 @@ import Code from '@components/molecules/layout/code';
 import Gallery from '@components/organisms/images/gallery';
 import Overview, { OverviewMeta } from '@components/organisms/layout/overview';
 import Sharing from '@components/organisms/widgets/sharing';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   PageLayoutProps,
 } from '@components/templates/page/page-layout';
-import { ProjectPreview, Repos } from '@ts/types/app';
+import {
+  type NextPageWithLayout,
+  type ProjectPreview,
+  type Repos,
+} from '@ts/types/app';
 import { loadTranslation, Messages } from '@utils/helpers/i18n';
 import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
 import { capitalize } from '@utils/helpers/strings';
@@ -17,7 +22,7 @@ import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useGithubApi, { RepoData } from '@utils/hooks/use-github-api';
 import useSettings from '@utils/hooks/use-settings';
 import { MDXComponents, NestedMDXComponents } from 'mdx/types';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -33,7 +38,7 @@ type ProjectPageProps = {
 /**
  * Project page.
  */
-const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
+const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
   const { id, intro, meta, title } = project;
   const { cover, dates, license, repos, seo, technologies } = meta;
   const intl = useIntl();
@@ -207,6 +212,9 @@ const ProjectPage: NextPage<ProjectPageProps> = ({ project }) => {
   );
 };
 
+ProjectPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<ProjectPageProps> = async ({
   locale,
   params,
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index 4a58269..d500b6b 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -2,16 +2,17 @@ import Link from '@components/atoms/links/link';
 import CardsList, {
   CardsListItem,
 } from '@components/organisms/layout/cards-list';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import PageContent, { meta } from '@content/pages/projects.mdx';
 import styles from '@styles/pages/projects.module.scss';
-import { ProjectCard } from '@ts/types/app';
+import { type NextPageWithLayout, type ProjectCard } from '@ts/types/app';
 import { loadTranslation, Messages } from '@utils/helpers/i18n';
 import { getProjectsCard } from '@utils/helpers/projects';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -25,7 +26,7 @@ type ProjectsPageProps = {
 /**
  * Projects page.
  */
-const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
+const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => {
   const { dates, seo, title } = meta;
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
     title,
@@ -122,6 +123,9 @@ const ProjectsPage: NextPage<ProjectsPageProps> = ({ projects }) => {
   );
 };
 
+ProjectsPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<ProjectsPageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index d88a293..09091c8 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -2,6 +2,7 @@ import Notice from '@components/atoms/layout/notice';
 import Spinner from '@components/atoms/loaders/spinner';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
 import {
@@ -14,7 +15,11 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
-import { type Article, type Meta } from '@ts/types/app';
+import {
+  type Article,
+  type Meta,
+  type NextPageWithLayout,
+} from '@ts/types/app';
 import {
   RawThematicPreview,
   RawTopicPreview,
@@ -29,7 +34,7 @@ import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticProps, NextPage } from 'next';
+import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -45,7 +50,7 @@ type SearchPageProps = {
 /**
  * Search page.
  */
-const SearchPage: NextPage<SearchPageProps> = ({
+const SearchPage: NextPageWithLayout<SearchPageProps> = ({
   thematicsList,
   topicsList,
 }) => {
@@ -283,6 +288,9 @@ const SearchPage: NextPage<SearchPageProps> = ({
   );
 };
 
+SearchPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 export const getStaticProps: GetStaticProps<SearchPageProps> = async ({
   locale,
 }) => {
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 348fe05..6277293 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -2,6 +2,7 @@ import Heading from '@components/atoms/headings/heading';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
@@ -12,7 +13,12 @@ import {
   getTotalTopics,
 } from '@services/graphql/topics';
 import styles from '@styles/pages/topic.module.scss';
-import { type Article, type PageLink, type Topic } from '@ts/types/app';
+import {
+  type Article,
+  type NextPageWithLayout,
+  type PageLink,
+  type Topic,
+} from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
@@ -21,7 +27,7 @@ import {
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -35,7 +41,10 @@ export type TopicPageProps = {
   translation: Messages;
 };
 
-const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
+const TopicPage: NextPageWithLayout<TopicPageProps> = ({
+  currentTopic,
+  topics,
+}) => {
   const { content, intro, meta, slug, title } = currentTopic;
   const {
     articles,
@@ -208,6 +217,9 @@ const TopicPage: NextPage<TopicPageProps> = ({ currentTopic, topics }) => {
   );
 };
 
+TopicPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 interface TopicParams extends ParsedUrlQuery {
   slug: string;
 }
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index 13ef0da..23e6a8b 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -1,6 +1,7 @@
 import Heading from '@components/atoms/headings/heading';
 import PostsList, { type Post } from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
+import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   type PageLayoutProps,
 } from '@components/templates/page/page-layout';
@@ -10,7 +11,12 @@ import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
-import { type Article, type PageLink, type Thematic } from '@ts/types/app';
+import {
+  type Article,
+  type NextPageWithLayout,
+  type PageLink,
+  type Thematic,
+} from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
@@ -19,7 +25,7 @@ import {
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
-import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
+import { GetStaticPaths, GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
@@ -33,7 +39,7 @@ export type ThematicPageProps = {
   translation: Messages;
 };
 
-const ThematicPage: NextPage<ThematicPageProps> = ({
+const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
   currentThematic,
   thematics,
 }) => {
@@ -191,6 +197,9 @@ const ThematicPage: NextPage<ThematicPageProps> = ({
   );
 };
 
+ThematicPage.getLayout = (page) =>
+  getLayout(page, { useGrid: true, withExtraPadding: true });
+
 interface ThematicParams extends ParsedUrlQuery {
   slug: string;
 }
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index a3b9889..feff5a5 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -1,3 +1,24 @@
+import { NextPage } from 'next';
+import { AppProps } from 'next/app';
+import { ReactElement, ReactNode } from 'react';
+
+export type NextPageWithLayoutOptions = {
+  withExtraPadding?: boolean;
+  isHome?: boolean;
+  useGrid?: boolean;
+};
+
+export type NextPageWithLayout<T = {}> = NextPage<T> & {
+  getLayout?: (
+    page: ReactElement,
+    options: NextPageWithLayoutOptions
+  ) => ReactNode;
+};
+
+export type AppPropsWithLayout = AppProps & {
+  Component: NextPageWithLayout;
+};
+
 export type ContentKind =
   | 'article'
   | 'comment'
-- 
cgit v1.2.3


From bbd63400f94b43fde04449e0c71d14763d893e6a Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Thu, 19 May 2022 19:46:24 +0200
Subject: refactor: rewrite Prism hooks and providers

It avoid some hydratation errors on project pages (not in article
however) and the hooks are now reusable.
---
 src/components/molecules/forms/motion-toggle.tsx |   3 +-
 src/components/molecules/layout/code.tsx         |  25 +---
 src/components/templates/page/page-layout.tsx    |   5 +-
 src/pages/article/[slug].tsx                     |  54 +++++--
 src/styles/pages/partials/_article-prism.scss    |   2 +-
 src/utils/helpers/strings.ts                     |  10 ++
 src/utils/hooks/use-add-classname.tsx            |  34 +++++
 src/utils/hooks/use-add-prism-class-attr.tsx     |  60 --------
 src/utils/hooks/use-attributes.tsx               |  35 ++++-
 src/utils/hooks/use-code-blocks-theme.tsx        |  22 ---
 src/utils/hooks/use-prism-plugins.tsx            | 115 --------------
 src/utils/hooks/use-prism.tsx                    | 182 +++++++++++++++++++++++
 src/utils/hooks/use-query-selector-all.tsx       |  12 +-
 src/utils/providers/prism-theme.tsx              |  78 ++++------
 14 files changed, 346 insertions(+), 291 deletions(-)
 create mode 100644 src/utils/hooks/use-add-classname.tsx
 delete mode 100644 src/utils/hooks/use-add-prism-class-attr.tsx
 delete mode 100644 src/utils/hooks/use-code-blocks-theme.tsx
 delete mode 100644 src/utils/hooks/use-prism-plugins.tsx
 create mode 100644 src/utils/hooks/use-prism.tsx

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx
index e3bb11a..cbe38fe 100644
--- a/src/components/molecules/forms/motion-toggle.tsx
+++ b/src/components/molecules/forms/motion-toggle.tsx
@@ -33,7 +33,8 @@ const MotionToggle: FC<MotionToggleProps> = ({
     value
   );
   useAttributes({
-    attribute: 'reducedMotion',
+    element: document.documentElement || undefined,
+    attribute: 'reduced-motion',
     value: `${isReduced}`,
   });
 
diff --git a/src/components/molecules/layout/code.tsx b/src/components/molecules/layout/code.tsx
index 2959ae5..f5b60b9 100644
--- a/src/components/molecules/layout/code.tsx
+++ b/src/components/molecules/layout/code.tsx
@@ -1,7 +1,5 @@
-import usePrismPlugins, {
-  type PrismPlugin,
-} from '@utils/hooks/use-prism-plugins';
-import { FC } from 'react';
+import usePrism, { OptionalPrismPlugin } from '@utils/hooks/use-prism';
+import { FC, useRef } from 'react';
 import styles from './code.module.scss';
 
 export type PrismLanguage =
@@ -35,15 +33,6 @@ export type PrismLanguage =
   | 'twig'
   | 'yaml';
 
-export type OptionalPrismPlugin = Extract<
-  PrismPlugin,
-  | 'command-line'
-  | 'diff-highlight'
-  | 'inline-color'
-  | 'line-highlight'
-  | 'line-numbers'
->;
-
 export type CodeProps = {
   /**
    * The code to highlight.
@@ -79,17 +68,19 @@ const Code: FC<CodeProps> = ({
   plugins = [],
   outputPattern = '#output#',
 }) => {
-  const { pluginsAttribute, pluginsClassName } = usePrismPlugins(plugins);
+  const wrapperRef = useRef<HTMLDivElement>(null);
+  const { attributes, className } = usePrism({ language, plugins });
 
   const outputAttribute = filterOutput
     ? { 'data-filter-output': outputPattern }
     : {};
 
   return (
-    <div className={styles.wrapper}>
+    <div className={styles.wrapper} ref={wrapperRef}>
       <pre
-        className={`language-${language} ${pluginsClassName}`}
-        {...pluginsAttribute}
+        className={className}
+        tabIndex={0}
+        {...attributes}
         {...outputAttribute}
       >
         <code className={`language-${language}`}>{children}</code>
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
index 54b8d6e..8ff44d5 100644
--- a/src/components/templates/page/page-layout.tsx
+++ b/src/components/templates/page/page-layout.tsx
@@ -20,7 +20,6 @@ import CommentsList, {
 import TableOfContents from '@components/organisms/widgets/table-of-contents';
 import { type SendCommentVars } from '@services/graphql/api';
 import { sendComment } from '@services/graphql/comments';
-import useCodeBlocksTheme from '@utils/hooks/use-code-blocks-theme';
 import useIsMounted from '@utils/hooks/use-is-mounted';
 import Script from 'next/script';
 import { FC, HTMLAttributes, ReactNode, useRef, useState } from 'react';
@@ -181,11 +180,9 @@ const PageLayout: FC<PageLayoutProps> = ({
    * @param {MetaData} meta - The metadata.
    */
   const hasMeta = (meta: MetaData) => {
-    return Object.values(meta).every((value) => value === null);
+    return Object.values(meta).every((value) => value);
   };
 
-  useCodeBlocksTheme(bodyRef);
-
   return (
     <>
       <Script
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index a3df43b..7abbabc 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -18,11 +18,11 @@ import {
   type NextPageWithLayout,
 } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
-import useAddPrismClassAttr from '@utils/hooks/use-add-prism-class-attr';
+import useAddClassName from '@utils/hooks/use-add-classname';
+import useAttributes from '@utils/hooks/use-attributes';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
-import usePrismPlugins, {
-  type PrismPlugin,
-} from '@utils/hooks/use-prism-plugins';
+import usePrism, { type OptionalPrismPlugin } from '@utils/hooks/use-prism';
+import useQuerySelectorAll from '@utils/hooks/use-query-selector-all';
 import useReadingTime from '@utils/hooks/use-reading-time';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps } from 'next';
@@ -199,14 +199,40 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     });
   };
 
-  const prismPlugins: PrismPlugin[] = ['command-line', 'line-numbers'];
-  const { pluginsAttribute, pluginsClassName } = usePrismPlugins(prismPlugins);
-  useAddPrismClassAttr({
-    attributes: {
-      'data-filter-output': '#output#"',
-    },
-    classNames: pluginsClassName,
-  });
+  const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers'];
+  const { attributes, className } = usePrism({ plugins: prismPlugins });
+  const lineNumbersClassName = className
+    .replace('command-line', '')
+    .replace(/\s\s+/g, ' ');
+  const commandLineClassName = className
+    .replace('line-numbers', '')
+    .replace(/\s\s+/g, ' ');
+
+  /**
+   * Replace a string with Prism classnames and attributes.
+   *
+   * @param {string} str - The found string.
+   * @returns {string} The classes and attributes.
+   */
+  const prismClassNameReplacer = (str: string): string => {
+    const wpBlockClassName = 'wp-block-code';
+    const languageArray = str.match(/language-[^\s|"]+/);
+    const languageClassName = languageArray ? `${languageArray[0]}` : '';
+
+    if (
+      str.includes('command-line') ||
+      (!str.includes('command-line') && str.includes('language-bash'))
+    ) {
+      return `class="${wpBlockClassName} ${commandLineClassName}${languageClassName}" tabindex="0" data-filter-output="#output#`;
+    }
+
+    return `class="${wpBlockClassName} ${lineNumbersClassName}${languageClassName}" tabindex="0`;
+  };
+
+  const contentWithPrismClasses = content.replaceAll(
+    /class="wp-block-code[^"]+/gm,
+    prismClassNameReplacer
+  );
 
   return (
     <>
@@ -226,7 +252,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
       <PageLayout
         allowComments={true}
         bodyAttributes={{
-          ...(pluginsAttribute as HTMLAttributes<HTMLDivElement>),
+          ...(attributes as HTMLAttributes<HTMLDivElement>),
         }}
         bodyClassName={styles.body}
         breadcrumb={breadcrumbItems}
@@ -254,7 +280,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
           />,
         ]}
       >
-        {content}
+        {contentWithPrismClasses}
       </PageLayout>
     </>
   );
diff --git a/src/styles/pages/partials/_article-prism.scss b/src/styles/pages/partials/_article-prism.scss
index 025e0c0..5412bbd 100644
--- a/src/styles/pages/partials/_article-prism.scss
+++ b/src/styles/pages/partials/_article-prism.scss
@@ -238,7 +238,7 @@
       > span {
         &::before {
           display: block;
-          content: "";
+          content: " ";
         }
 
         &[data-user]::before {
diff --git a/src/utils/helpers/strings.ts b/src/utils/helpers/strings.ts
index 5d90161..1af0ca2 100644
--- a/src/utils/helpers/strings.ts
+++ b/src/utils/helpers/strings.ts
@@ -27,3 +27,13 @@ export const slugify = (text: string): string => {
 export const capitalize = (text: string): string => {
   return text.replace(/^\w/, (firstLetter) => firstLetter.toUpperCase());
 };
+
+/**
+ * Convert a text from kebab case (foo-bar) to camel case (fooBar).
+ *
+ * @param {string} text - A text to transform.
+ * @returns {string} The text in camel case.
+ */
+export const fromKebabCaseToCamelCase = (text: string): string => {
+  return text.replace(/-./g, (x) => x[1].toUpperCase());
+};
diff --git a/src/utils/hooks/use-add-classname.tsx b/src/utils/hooks/use-add-classname.tsx
new file mode 100644
index 0000000..0584084
--- /dev/null
+++ b/src/utils/hooks/use-add-classname.tsx
@@ -0,0 +1,34 @@
+import { useCallback, useEffect } from 'react';
+
+export type UseAddClassNameProps = {
+  className: string;
+  element?: HTMLElement;
+  elements?: NodeListOf<HTMLElement> | HTMLElement[];
+};
+
+/**
+ * Add className to the given element(s).
+ *
+ * @param {UseAddClassNameProps} props - An object with classnames and one or more elements.
+ */
+const useAddClassName = ({
+  className,
+  element,
+  elements,
+}: UseAddClassNameProps) => {
+  const classNames = className.split(' ').filter((string) => string !== '');
+
+  const setClassName = useCallback(
+    (el: HTMLElement) => {
+      el.classList.add(...classNames);
+    },
+    [classNames]
+  );
+
+  useEffect(() => {
+    if (element) setClassName(element);
+    if (elements && elements.length > 0) elements.forEach(setClassName);
+  }, [element, elements, setClassName]);
+};
+
+export default useAddClassName;
diff --git a/src/utils/hooks/use-add-prism-class-attr.tsx b/src/utils/hooks/use-add-prism-class-attr.tsx
deleted file mode 100644
index 7d33cc2..0000000
--- a/src/utils/hooks/use-add-prism-class-attr.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useCallback, useEffect, useState } from 'react';
-
-export type AttributesMap = {
-  [key: string]: string;
-};
-
-export type useAddPrismClassAttrProps = {
-  attributes?: AttributesMap;
-  classNames?: string;
-};
-
-/**
- * Add classnames and/or attributes to pre elements.
- *
- * @param props - An object of attributes and classnames.
- */
-const useAddPrismClassAttr = ({
-  attributes,
-  classNames,
-}: useAddPrismClassAttrProps) => {
-  const [elements, setElements] = useState<HTMLPreElement[]>([]);
-
-  useEffect(() => {
-    const targetElements = document.querySelectorAll('pre');
-    setElements(Array.from(targetElements));
-  }, []);
-
-  const setClassNameAndAttributes = useCallback(
-    (array: HTMLElement[]) => {
-      array.forEach((el) => {
-        if (classNames) {
-          const classNamesArray = classNames.split(' ');
-          const isCommandLine = el.classList.contains('command-line');
-          const removedClassName = isCommandLine
-            ? 'line-numbers'
-            : 'command-line';
-          const filteredClassNames = classNamesArray.filter(
-            (className) => className !== removedClassName
-          );
-          filteredClassNames.forEach((className) =>
-            el.classList.add(className)
-          );
-        }
-
-        if (attributes) {
-          for (const [key, value] of Object.entries(attributes)) {
-            el.setAttribute(key, value);
-          }
-        }
-      });
-    },
-    [attributes, classNames]
-  );
-
-  useEffect(() => {
-    if (elements.length > 0) setClassNameAndAttributes(elements);
-  }, [elements, setClassNameAndAttributes]);
-};
-
-export default useAddPrismClassAttr;
diff --git a/src/utils/hooks/use-attributes.tsx b/src/utils/hooks/use-attributes.tsx
index 97a7b43..6d18048 100644
--- a/src/utils/hooks/use-attributes.tsx
+++ b/src/utils/hooks/use-attributes.tsx
@@ -1,10 +1,15 @@
-import { useEffect } from 'react';
+import { fromKebabCaseToCamelCase } from '@utils/helpers/strings';
+import { useCallback, useEffect } from 'react';
 
 export type useAttributesProps = {
   /**
    * An HTML element.
    */
   element?: HTMLElement;
+  /**
+   * A node list of HTML Element.
+   */
+  elements?: NodeListOf<HTMLElement> | HTMLElement[];
   /**
    * The attribute name.
    */
@@ -20,14 +25,28 @@ export type useAttributesProps = {
  *
  * @param props - An object with element, attribute name and value.
  */
-const useAttributes = ({ element, attribute, value }: useAttributesProps) => {
+const useAttributes = ({
+  element,
+  elements,
+  attribute,
+  value,
+}: useAttributesProps) => {
+  const setAttribute = useCallback(
+    (el: HTMLElement) => {
+      if (attribute.startsWith('data')) {
+        el.setAttribute(attribute, value);
+      } else {
+        const camelCaseAttribute = fromKebabCaseToCamelCase(attribute);
+        el.dataset[camelCaseAttribute] = value;
+      }
+    },
+    [attribute, value]
+  );
+
   useEffect(() => {
-    if (element) {
-      element.dataset[attribute] = value;
-    } else {
-      document.documentElement.dataset[attribute] = value;
-    }
-  }, [attribute, element, value]);
+    if (element) setAttribute(element);
+    if (elements && elements.length > 0) elements.forEach(setAttribute);
+  }, [element, elements, setAttribute]);
 };
 
 export default useAttributes;
diff --git a/src/utils/hooks/use-code-blocks-theme.tsx b/src/utils/hooks/use-code-blocks-theme.tsx
deleted file mode 100644
index beb7b29..0000000
--- a/src/utils/hooks/use-code-blocks-theme.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { usePrismTheme } from '@utils/providers/prism-theme';
-import { useRouter } from 'next/router';
-import { RefObject, useEffect, useState } from 'react';
-import useIsMounted from './use-is-mounted';
-
-const useCodeBlocksTheme = (el: RefObject<HTMLDivElement>) => {
-  const [preElements, setPreElements] = useState<NodeListOf<HTMLPreElement>>();
-  const isMounted = useIsMounted(el);
-  const { setCodeBlocks } = usePrismTheme();
-  const { asPath } = useRouter();
-
-  useEffect(() => {
-    const result = document.querySelectorAll<HTMLPreElement>('pre');
-    setPreElements(result);
-  }, [asPath]);
-
-  useEffect(() => {
-    isMounted && preElements && setCodeBlocks(preElements);
-  }, [isMounted, preElements, setCodeBlocks]);
-};
-
-export default useCodeBlocksTheme;
diff --git a/src/utils/hooks/use-prism-plugins.tsx b/src/utils/hooks/use-prism-plugins.tsx
deleted file mode 100644
index c4959ac..0000000
--- a/src/utils/hooks/use-prism-plugins.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import Prism from 'prismjs';
-import { useEffect, useMemo } from 'react';
-import { useIntl } from 'react-intl';
-
-export type PrismPlugin =
-  | 'autoloader'
-  | 'color-scheme'
-  | 'command-line'
-  | 'copy-to-clipboard'
-  | 'diff-highlight'
-  | 'inline-color'
-  | 'line-highlight'
-  | 'line-numbers'
-  | 'match-braces'
-  | 'normalize-whitespace'
-  | 'show-language'
-  | 'toolbar';
-
-/**
- * Import and configure all given Prism plugins.
- *
- * @param {PrismPlugin[]} prismPlugins - The Prism plugins to activate.
- */
-const loadPrismPlugins = async (prismPlugins: PrismPlugin[]) => {
-  for (const plugin of prismPlugins) {
-    try {
-      if (plugin === 'color-scheme') {
-        await import(`@utils/plugins/prism-${plugin}`);
-      } else {
-        await import(`prismjs/plugins/${plugin}/prism-${plugin}.min.js`);
-      }
-
-      if (plugin === 'autoloader') {
-        Prism.plugins.autoloader.languages_path = '/prism/';
-      }
-    } catch (error) {
-      console.error(
-        'usePrismPlugins: an error occurred while loading Prism plugins.'
-      );
-      console.error(error);
-    }
-  }
-};
-
-/**
- * Load both the given Prism plugins and the default plugins.
- *
- * @param {PrismPlugin[]} plugins - The Prism plugins to activate.
- */
-const usePrismPlugins = (plugins: PrismPlugin[]) => {
-  const intl = useIntl();
-
-  const copyText = intl.formatMessage({
-    defaultMessage: 'Copy',
-    description: 'usePrismPlugins: copy button text (not clicked)',
-    id: 'FIE/eC',
-  });
-  const copiedText = intl.formatMessage({
-    defaultMessage: 'Copied!',
-    description: 'usePrismPlugins: copy button text (clicked)',
-    id: 'MzLdEl',
-  });
-  const errorText = intl.formatMessage({
-    defaultMessage: 'Use Ctrl+c to copy',
-    description: 'usePrismPlugins: copy button error text',
-    id: '0XePFn',
-  });
-  const darkTheme = intl.formatMessage({
-    defaultMessage: 'Dark Theme 🌙',
-    description: 'usePrismPlugins: toggle dark theme button text',
-    id: 'jo9vr5',
-  });
-  const lightTheme = intl.formatMessage({
-    defaultMessage: 'Light Theme 🌞',
-    description: 'usePrismPlugins: toggle light theme button text',
-    id: '6EUEtH',
-  });
-
-  const attributes = {
-    'data-prismjs-copy': copyText,
-    'data-prismjs-copy-success': copiedText,
-    'data-prismjs-copy-error': errorText,
-    'data-prismjs-color-scheme-dark': darkTheme,
-    'data-prismjs-color-scheme-light': lightTheme,
-  };
-
-  const defaultPlugins: PrismPlugin[] = useMemo(
-    () => [
-      'toolbar',
-      'autoloader',
-      'show-language',
-      'copy-to-clipboard',
-      'color-scheme',
-      'match-braces',
-      'normalize-whitespace',
-    ],
-    []
-  );
-
-  useEffect(() => {
-    loadPrismPlugins([...defaultPlugins, ...plugins]).then(() => {
-      Prism.highlightAll();
-    });
-  }, [defaultPlugins, plugins]);
-
-  const defaultPluginsClasses = 'match-braces';
-  const pluginsClasses = plugins.join(' ');
-
-  return {
-    pluginsAttribute: attributes,
-    pluginsClassName: `${defaultPluginsClasses} ${pluginsClasses}`,
-  };
-};
-
-export default usePrismPlugins;
diff --git a/src/utils/hooks/use-prism.tsx b/src/utils/hooks/use-prism.tsx
new file mode 100644
index 0000000..ef1a4c8
--- /dev/null
+++ b/src/utils/hooks/use-prism.tsx
@@ -0,0 +1,182 @@
+import Prism from 'prismjs';
+import { useEffect, useMemo } from 'react';
+import { useIntl } from 'react-intl';
+
+const PRISM_PLUGINS = [
+  'autoloader',
+  'color-scheme',
+  'command-line',
+  'copy-to-clipboard',
+  'diff-highlight',
+  'inline-color',
+  'line-highlight',
+  'line-numbers',
+  'match-braces',
+  'normalize-whitespace',
+  'show-language',
+  'toolbar',
+] as const;
+
+export type PrismPlugin = typeof PRISM_PLUGINS[number];
+
+export type DefaultPrismPlugin = Extract<
+  PrismPlugin,
+  | 'autoloader'
+  | 'color-scheme'
+  | 'copy-to-clipboard'
+  | 'match-braces'
+  | 'normalize-whitespace'
+  | 'show-language'
+  | 'toolbar'
+>;
+
+export type OptionalPrismPlugin = Exclude<PrismPlugin, DefaultPrismPlugin>;
+
+export type PrismLanguage =
+  | 'apacheconf'
+  | 'bash'
+  | 'css'
+  | 'diff'
+  | 'docker'
+  | 'editorconfig'
+  | 'ejs'
+  | 'git'
+  | 'graphql'
+  | 'html'
+  | 'ignore'
+  | 'ini'
+  | 'javascript'
+  | 'jsdoc'
+  | 'json'
+  | 'jsx'
+  | 'makefile'
+  | 'markup'
+  | 'php'
+  | 'phpdoc'
+  | 'regex'
+  | 'scss'
+  | 'shell-session'
+  | 'smarty'
+  | 'tcl'
+  | 'toml'
+  | 'tsx'
+  | 'twig'
+  | 'yaml';
+
+export type PrismAttributes = {
+  'data-prismjs-copy': string;
+  'data-prismjs-copy-success': string;
+  'data-prismjs-copy-error': string;
+  'data-prismjs-color-scheme-dark': string;
+  'data-prismjs-color-scheme-light': string;
+};
+
+export type UsePrismProps = {
+  language?: PrismLanguage;
+  plugins: OptionalPrismPlugin[];
+};
+
+export type UsePrismReturn = {
+  attributes: PrismAttributes;
+  className: string;
+};
+
+/**
+ * Import and configure all given Prism plugins.
+ *
+ * @param {PrismPlugin[]} plugins - The Prism plugins to activate.
+ */
+const loadPrismPlugins = async (plugins: PrismPlugin[]) => {
+  for (const plugin of plugins) {
+    try {
+      if (plugin === 'color-scheme') {
+        await import(`@utils/plugins/prism-${plugin}`);
+      } else {
+        await import(`prismjs/plugins/${plugin}/prism-${plugin}.min.js`);
+      }
+
+      if (plugin === 'autoloader') {
+        Prism.plugins.autoloader.languages_path = '/prism/';
+      }
+    } catch (error) {
+      console.error('usePrism: an error occurred while loading Prism plugins.');
+      console.error(error);
+    }
+  }
+};
+
+/**
+ * Use Prism and its plugins.
+ *
+ * @param {UsePrismProps} props - An object of options.
+ * @returns {UsePrismReturn} An object of data.
+ */
+const usePrism = ({ language, plugins }: UsePrismProps): UsePrismReturn => {
+  /**
+   * The order matter. Toolbar must be loaded before some other plugins.
+   */
+  const defaultPlugins: DefaultPrismPlugin[] = useMemo(
+    () => [
+      'toolbar',
+      'autoloader',
+      'show-language',
+      'copy-to-clipboard',
+      'color-scheme',
+      'match-braces',
+      'normalize-whitespace',
+    ],
+    []
+  );
+
+  useEffect(() => {
+    loadPrismPlugins([...defaultPlugins, ...plugins]).then(() => {
+      Prism.highlightAll();
+    });
+  }, [defaultPlugins, plugins]);
+
+  const defaultClassName = 'match-braces';
+  const languageClassName = language ? `language-${language}` : '';
+  const pluginsClassName = plugins.join(' ');
+  const className = `${defaultClassName} ${pluginsClassName} ${languageClassName}`;
+
+  const intl = useIntl();
+  const copyText = intl.formatMessage({
+    defaultMessage: 'Copy',
+    description: 'usePrism: copy button text (not clicked)',
+    id: '6GySNl',
+  });
+  const copiedText = intl.formatMessage({
+    defaultMessage: 'Copied!',
+    description: 'usePrism: copy button text (clicked)',
+    id: 'nsw6Th',
+  });
+  const errorText = intl.formatMessage({
+    defaultMessage: 'Use Ctrl+c to copy',
+    description: 'usePrism: copy button error text',
+    id: 'lKhTGM',
+  });
+  const darkTheme = intl.formatMessage({
+    defaultMessage: 'Dark Theme 🌙',
+    description: 'usePrism: toggle dark theme button text',
+    id: 'QLisK6',
+  });
+  const lightTheme = intl.formatMessage({
+    defaultMessage: 'Light Theme 🌞',
+    description: 'usePrism: toggle light theme button text',
+    id: 'hHVgW3',
+  });
+  const attributes = {
+    'data-prismjs-copy': copyText,
+    'data-prismjs-copy-success': copiedText,
+    'data-prismjs-copy-error': errorText,
+    'data-prismjs-color-scheme-dark': darkTheme,
+    'data-prismjs-color-scheme-light': lightTheme,
+  };
+
+  return {
+    attributes,
+    className,
+  };
+};
+
+export default usePrism;
diff --git a/src/utils/hooks/use-query-selector-all.tsx b/src/utils/hooks/use-query-selector-all.tsx
index dbeec90..6ac8a08 100644
--- a/src/utils/hooks/use-query-selector-all.tsx
+++ b/src/utils/hooks/use-query-selector-all.tsx
@@ -1,14 +1,22 @@
+import { useRouter } from 'next/router';
 import { useEffect, useState } from 'react';
 
+/**
+ * Use `document.querySelectorAll`.
+ *
+ * @param {string} query - A query.
+ * @returns {NodeListOf<HTMLElementTagNameMap[T]|undefined>} - The node list.
+ */
 const useQuerySelectorAll = <T extends keyof HTMLElementTagNameMap>(
   query: string
-) => {
+): NodeListOf<HTMLElementTagNameMap[T]> | undefined => {
   const [elements, setElements] =
     useState<NodeListOf<HTMLElementTagNameMap[T]>>();
+  const { asPath } = useRouter();
 
   useEffect(() => {
     setElements(document.querySelectorAll(query));
-  }, [query]);
+  }, [asPath, query]);
 
   return elements;
 };
diff --git a/src/utils/providers/prism-theme.tsx b/src/utils/providers/prism-theme.tsx
index 23a2a36..dd8feb7 100644
--- a/src/utils/providers/prism-theme.tsx
+++ b/src/utils/providers/prism-theme.tsx
@@ -1,4 +1,6 @@
-import { LocalStorage } from '@services/local-storage';
+import useAttributes from '@utils/hooks/use-attributes';
+import useLocalStorage from '@utils/hooks/use-local-storage';
+import useQuerySelectorAll from '@utils/hooks/use-query-selector-all';
 import {
   createContext,
   FC,
@@ -10,7 +12,7 @@ import {
 } from 'react';
 
 export type PrismTheme = 'dark' | 'light' | 'system';
-export type ResolvedPrismTheme = 'dark' | 'light';
+export type ResolvedPrismTheme = Exclude<PrismTheme, 'system'>;
 
 export type UsePrismThemeProps = {
   themes: PrismTheme[];
@@ -18,7 +20,6 @@ export type UsePrismThemeProps = {
   setTheme: (theme: PrismTheme) => void;
   resolvedTheme?: ResolvedPrismTheme;
   codeBlocks?: NodeListOf<HTMLPreElement>;
-  setCodeBlocks: (codeBlocks: NodeListOf<HTMLPreElement>) => void;
 };
 
 export type PrismThemeProviderProps = {
@@ -33,14 +34,16 @@ export const PrismThemeContext = createContext<UsePrismThemeProps>({
   setTheme: (_) => {
     // This is intentional.
   },
-  setCodeBlocks: (_) => {
-    // This is intentional.
-  },
 });
 
 export const usePrismTheme = () => useContext(PrismThemeContext);
 
-const prefersDarkScheme = () => {
+/**
+ * Check if user prefers dark color scheme.
+ *
+ * @returns {boolean|undefined} True if `prefers-color-scheme` is set to `dark`.
+ */
+const prefersDarkScheme = (): boolean | undefined => {
   if (typeof window === 'undefined') return;
 
   return (
@@ -49,40 +52,35 @@ const prefersDarkScheme = () => {
   );
 };
 
+/**
+ * Check if a given string is a Prism theme name.
+ *
+ * @param {string} theme - A string.
+ * @returns {boolean} True if the given string match a Prism theme name.
+ */
 const isValidTheme = (theme: string): boolean => {
   return theme === 'dark' || theme === 'light' || theme === 'system';
 };
 
-const getTheme = (key: string): PrismTheme | undefined => {
-  if (typeof window === 'undefined') return undefined;
-  const storageValue = LocalStorage.get<string>(key);
-
-  return storageValue && isValidTheme(storageValue)
-    ? (storageValue as PrismTheme)
-    : undefined;
-};
-
 export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({
   attribute = 'data-prismjs-color-scheme-current',
   storageKey = 'prismjs-color-scheme',
   themes = ['dark', 'light', 'system'],
   children,
 }) => {
+  /**
+   * Retrieve the theme to use depending on `prefers-color-scheme`.
+   */
   const getThemeFromSystem = useCallback(() => {
     return prefersDarkScheme() ? 'dark' : 'light';
   }, []);
 
-  const [prismTheme, setPrismTheme] = useState<PrismTheme>(
-    getTheme(storageKey) || 'system'
-  );
-
-  const updateTheme = (theme: PrismTheme) => {
-    setPrismTheme(theme);
-  };
+  const { value: prismTheme, setValue: setPrismTheme } =
+    useLocalStorage<PrismTheme>(storageKey, 'system');
 
   useEffect(() => {
-    LocalStorage.set(storageKey, prismTheme);
-  }, [prismTheme, storageKey]);
+    if (!isValidTheme(prismTheme)) setPrismTheme('system');
+  }, [prismTheme, setPrismTheme]);
 
   const [resolvedTheme, setResolvedTheme] = useState<ResolvedPrismTheme>();
 
@@ -109,22 +107,12 @@ export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({
         .removeEventListener('change', updateResolvedTheme);
   }, [updateResolvedTheme]);
 
-  const [preTags, setPreTags] = useState<NodeListOf<HTMLPreElement>>();
-
-  const updatePreTags = useCallback((tags: NodeListOf<HTMLPreElement>) => {
-    setPreTags(tags);
-  }, []);
-
-  const updatePreTagsAttribute = useCallback(() => {
-    preTags?.forEach((pre) => {
-      pre.setAttribute(attribute, prismTheme);
-    });
-  }, [attribute, preTags, prismTheme]);
-
-  useEffect(() => {
-    updatePreTagsAttribute();
-  }, [updatePreTagsAttribute, prismTheme]);
+  const preTags = useQuerySelectorAll<'pre'>('pre');
+  useAttributes({ elements: preTags, attribute, value: prismTheme });
 
+  /**
+   * Listen for changes on pre attributes and update theme.
+   */
   const listenAttributeChange = useCallback(
     (pre: HTMLPreElement) => {
       var observer = new MutationObserver(function (mutations) {
@@ -139,15 +127,12 @@ export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({
         attributeFilter: [attribute],
       });
     },
-    [attribute]
+    [attribute, setPrismTheme]
   );
 
   useEffect(() => {
     if (!preTags) return;
-
-    preTags.forEach((pre) => {
-      listenAttributeChange(pre);
-    });
+    preTags.forEach(listenAttributeChange);
   }, [preTags, listenAttributeChange]);
 
   return (
@@ -155,9 +140,8 @@ export const PrismThemeProvider: FC<PrismThemeProviderProps> = ({
       value={{
         themes,
         theme: prismTheme,
-        setTheme: updateTheme,
+        setTheme: setPrismTheme,
         codeBlocks: preTags,
-        setCodeBlocks: updatePreTags,
         resolvedTheme,
       }}
     >
-- 
cgit v1.2.3


From 802548124071039f83ebfa2605e839ded82b5689 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Fri, 20 May 2022 22:22:35 +0200
Subject: chore: update some styles

* Improve wp-blocks-columns, wp-block-quote and some images
* Make Sharing widget consistent between pages
* Remove margin option from lists
* Fix Topic logo alignement
---
 src/components/atoms/lists/list.module.scss        | 10 ------
 src/components/atoms/lists/list.tsx                | 12 ++-----
 src/components/organisms/layout/cards-list.tsx     |  1 -
 .../organisms/layout/posts-list.module.scss        |  9 ++++-
 src/components/organisms/layout/posts-list.tsx     |  4 +--
 .../organisms/widgets/links-list-widget.tsx        |  1 -
 src/pages/article/[slug].tsx                       |  5 +--
 src/pages/projets/[slug].tsx                       |  9 +++--
 src/pages/sujet/[slug].tsx                         |  2 +-
 src/styles/pages/partials/_article-wp-blocks.scss  | 38 +++++++++++-----------
 src/styles/pages/project.module.scss               | 10 ++++++
 src/styles/pages/topic.module.scss                 |  2 +-
 12 files changed, 51 insertions(+), 52 deletions(-)
 create mode 100644 src/styles/pages/project.module.scss

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/components/atoms/lists/list.module.scss b/src/components/atoms/lists/list.module.scss
index f647072..95f9b40 100644
--- a/src/components/atoms/lists/list.module.scss
+++ b/src/components/atoms/lists/list.module.scss
@@ -7,10 +7,6 @@
     color: var(--color-primary-dark);
   }
 
-  & & {
-    margin-top: var(--spacing-2xs);
-  }
-
   &--ordered {
     padding: 0;
     counter-reset: li;
@@ -33,12 +29,6 @@
     padding: 0 0 0 var(--spacing-sm);
   }
 
-  &--has-margin &__item {
-    &:not(:last-child) {
-      margin-bottom: var(--spacing-2xs);
-    }
-  }
-
   &--flex {
     @extend %reset-list;
 
diff --git a/src/components/atoms/lists/list.tsx b/src/components/atoms/lists/list.tsx
index 711ade1..aa0a241 100644
--- a/src/components/atoms/lists/list.tsx
+++ b/src/components/atoms/lists/list.tsx
@@ -33,10 +33,6 @@ export type ListProps = {
    * The list kind.
    */
   kind?: 'ordered' | 'unordered' | 'flex';
-  /**
-   * Set margin between list items. Default: true.
-   */
-  withMargin?: boolean;
 };
 
 /**
@@ -49,11 +45,9 @@ const List: FC<ListProps> = ({
   items,
   itemsClassName = '',
   kind = 'unordered',
-  withMargin = true,
 }) => {
   const ListTag = kind === 'ordered' ? 'ol' : 'ul';
   const kindClass = `list--${kind}`;
-  const marginClass = withMargin ? 'list--has-margin' : 'list--no-margin';
 
   /**
    * Retrieve the list items.
@@ -66,7 +60,7 @@ const List: FC<ListProps> = ({
         {value}
         {child && (
           <ListTag
-            className={`${styles.list} ${styles[kindClass]} ${styles[marginClass]} ${className}`}
+            className={`${styles.list} ${styles[kindClass]} ${className}`}
           >
             {getItems(child)}
           </ListTag>
@@ -76,9 +70,7 @@ const List: FC<ListProps> = ({
   };
 
   return (
-    <ListTag
-      className={`${styles.list} ${styles[kindClass]} ${styles[marginClass]} ${className}`}
-    >
+    <ListTag className={`${styles.list} ${styles[kindClass]} ${className}`}>
       {getItems(items)}
     </ListTag>
   );
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
index 1558d7c..0168bb2 100644
--- a/src/components/organisms/layout/cards-list.tsx
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -77,7 +77,6 @@ const CardsList: FC<CardsListProps> = ({
     <List
       kind="flex"
       items={getCards(items)}
-      withMargin={false}
       className={`${styles.wrapper} ${styles[kindModifier]} ${className}`}
     />
   );
diff --git a/src/components/organisms/layout/posts-list.module.scss b/src/components/organisms/layout/posts-list.module.scss
index a006914..b09bb12 100644
--- a/src/components/organisms/layout/posts-list.module.scss
+++ b/src/components/organisms/layout/posts-list.module.scss
@@ -3,6 +3,10 @@
 @use "@styles/abstracts/placeholders";
 
 .section {
+  &:not(:last-of-type) {
+    margin-bottom: var(--spacing-md);
+  }
+
   @include mix.media("screen") {
     @include mix.dimensions("md") {
       display: grid;
@@ -17,8 +21,11 @@
   @extend %reset-ordered-list;
 
   .item {
-    margin-bottom: var(--spacing-md);
     border-bottom: fun.convert-px(1) solid var(--color-border);
+
+    &:not(:last-child) {
+      margin-bottom: var(--spacing-md);
+    }
   }
 }
 
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index 50192dd..608130e 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -100,6 +100,8 @@ const PostsList: FC<PostsListProps> = ({
   const isMounted = useIsMounted(listRef);
   const { blog } = useSettings();
 
+  const lastPostId = posts.length ? posts[posts.length - 1].id : 0;
+
   /**
    * Retrieve the list of posts.
    *
@@ -111,8 +113,6 @@ const PostsList: FC<PostsListProps> = ({
     allPosts: Post[],
     headingLevel: HeadingLevel = 2
   ): JSX.Element => {
-    const lastPostId = allPosts[allPosts.length - 1].id;
-
     return (
       <ol className={styles.list} ref={listRef}>
         {allPosts.map(({ id, ...post }) => (
diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx
index 90dae4a..a9c677b 100644
--- a/src/components/organisms/widgets/links-list-widget.tsx
+++ b/src/components/organisms/widgets/links-list-widget.tsx
@@ -75,7 +75,6 @@ const LinksListWidget: FC<LinksListWidgetProps> = ({
       <List
         items={getListItems(items)}
         kind={kind}
-        withMargin={false}
         className={`${styles.list} ${styles[listKindClass]} ${className}`}
         itemsClassName={styles.list__item}
       />
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 7abbabc..812fffe 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -99,10 +99,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
             target={`/sujet/${topic.slug}`}
             className={styles.btn}
           >
-            {topic.logo && (
-              <ResponsiveImage className={styles.btn__icon} {...topic.logo} />
-            )}{' '}
-            {topic.name}
+            {topic.logo && <ResponsiveImage {...topic.logo} />} {topic.name}
           </ButtonLink>
         );
       }),
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index cf7d0dc..e9b36fa 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -10,6 +10,7 @@ import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
   PageLayoutProps,
 } from '@components/templates/page/page-layout';
+import styles from '@styles/pages/project.module.scss';
 import {
   type NextPageWithLayout,
   type ProjectPreview,
@@ -53,7 +54,7 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
   const components: NestedMDXComponents = {
     Code: (props) => <Code {...props} />,
     Gallery: (props) => <Gallery {...props} />,
-    Image: (props) => <ResponsiveImage {...props} />,
+    Image: (props) => <ResponsiveImage withBorders={true} {...props} />,
     Link: (props) => <Link {...props} />,
     pre: ({ children }) => <Code {...children.props} />,
   };
@@ -66,7 +67,10 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
-    update: dates.update ? { date: dates.update } : undefined,
+    update:
+      dates.update && dates.update !== dates.publication
+        ? { date: dates.update }
+        : undefined,
   };
 
   /**
@@ -202,6 +206,7 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
               'linkedin',
               'twitter',
             ]}
+            className={styles.widget}
           />,
         ]}
       >
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 271f4ec..b4845eb 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -191,7 +191,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
             : []
         }
       >
-        <div dangerouslySetInnerHTML={{ __html: content }} />
+        {content && <div dangerouslySetInnerHTML={{ __html: content }} />}
         {articles && (
           <>
             <Heading level={2}>
diff --git a/src/styles/pages/partials/_article-wp-blocks.scss b/src/styles/pages/partials/_article-wp-blocks.scss
index 33e59a1..86be062 100644
--- a/src/styles/pages/partials/_article-wp-blocks.scss
+++ b/src/styles/pages/partials/_article-wp-blocks.scss
@@ -7,13 +7,12 @@
     margin: var(--spacing-sm) 0;
     padding: var(--spacing-sm);
     position: relative;
-    border: fun.convert-px(1) solid var(--color-primary-lighter);
-    border-left: fun.convert-px(5) solid var(--color-primary-lighter);
-    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-        var(--color-shadow-light),
-      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-        var(--color-shadow-light);
+    border: fun.convert-px(1) solid var(--color-border-dark);
+    border-left: fun.convert-px(5) solid var(--color-border-dark);
+    box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0
+        var(--color-shadow),
+      fun.convert-px(3) fun.convert-px(3) fun.convert-px(4) 0
+        var(--color-shadow);
     font-style: italic;
 
     > *:last-child {
@@ -21,6 +20,7 @@
     }
 
     cite {
+      color: var(--color-fg-light);
       font-size: var(--font-size-sm);
       font-style: normal;
       font-weight: 600;
@@ -62,18 +62,26 @@
     }
   }
 
+  .wp-block-image,
+  .wp-block-video {
+    padding: fun.convert-px(4);
+    border: fun.convert-px(1) solid var(--color-border);
+    box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0
+      var(--color-shadow);
+  }
+
   .wp-block-image {
     display: flex;
     flex-flow: column;
     width: fit-content;
     margin: 0 auto;
-    padding: fun.convert-px(4);
     position: relative;
-    border: fun.convert-px(1) solid var(--color-border);
-    box-shadow: fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0
-      var(--color-shadow);
     text-align: center;
 
+    img {
+      margin: auto;
+    }
+
     figcaption {
       margin-top: fun.convert-px(4);
       font-size: var(--font-size-sm);
@@ -166,12 +174,4 @@
       object-fit: cover;
     }
   }
-
-  .wp-block-video {
-    box-shadow: 0 0 fun.convert-px(1) 0 var(--color-shadow),
-      fun.convert-px(2) fun.convert-px(2) fun.convert-px(2) 0
-        var(--color-shadow-light),
-      fun.convert-px(3) fun.convert-px(3) fun.convert-px(6) 0
-        var(--color-shadow-light);
-  }
 }
diff --git a/src/styles/pages/project.module.scss b/src/styles/pages/project.module.scss
new file mode 100644
index 0000000..3b1b5cc
--- /dev/null
+++ b/src/styles/pages/project.module.scss
@@ -0,0 +1,10 @@
+@use "@styles/abstracts/mixins" as mix;
+
+.widget {
+  @include mix.media("screen") {
+    @include mix.dimensions("md") {
+      width: min-content;
+      gap: var(--spacing-2xs);
+    }
+  }
+}
diff --git a/src/styles/pages/topic.module.scss b/src/styles/pages/topic.module.scss
index badb694..fd5f742 100644
--- a/src/styles/pages/topic.module.scss
+++ b/src/styles/pages/topic.module.scss
@@ -2,5 +2,5 @@
 
 .logo {
   max-width: fun.convert-px(50);
-  margin-right: var(--spacing-xs);
+  margin: 0 var(--spacing-xs) 0 0;
 }
-- 
cgit v1.2.3


From 34e216546151eaf8a0a3cbb0bc8b65dae4c63bf2 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 23 May 2022 14:07:02 +0200
Subject: refactor: reduce the number of data transformation

---
 src/components/molecules/layout/card.tsx           |  22 +---
 src/components/organisms/layout/cards-list.tsx     |  36 +++---
 .../organisms/layout/comment.fixture.tsx           |  41 +++++++
 .../organisms/layout/comment.stories.tsx           |  28 ++---
 src/components/organisms/layout/comment.test.tsx   |  39 ++-----
 src/components/organisms/layout/comment.tsx        |  98 ++++++++---------
 .../organisms/layout/comments-list.fixture.tsx     | 106 ++++++++++++++++++
 .../organisms/layout/comments-list.stories.tsx     |  59 +---------
 .../organisms/layout/comments-list.test.tsx        |  64 +----------
 src/components/organisms/layout/comments-list.tsx  |  11 +-
 .../organisms/layout/posts-list.fixture.tsx        |  61 +++++++++++
 .../organisms/layout/posts-list.stories.tsx        |  64 +----------
 .../organisms/layout/posts-list.test.tsx           |  64 +----------
 src/components/organisms/layout/posts-list.tsx     |   4 +-
 .../organisms/layout/summary.fixture.tsx           |  25 +++++
 .../organisms/layout/summary.stories.tsx           |  28 +----
 src/components/organisms/layout/summary.test.tsx   |  44 ++------
 src/components/organisms/layout/summary.tsx        |  73 ++++---------
 .../templates/page/page-layout.stories.tsx         | 121 +--------------------
 src/components/templates/page/page-layout.test.tsx |  13 +--
 src/pages/article/[slug].tsx                       |  43 ++------
 src/pages/blog/index.tsx                           |  75 +------------
 src/pages/blog/page/[number].tsx                   |  66 +----------
 src/pages/projets/[slug].tsx                       |  14 ++-
 src/pages/projets/index.tsx                        |   4 +-
 src/pages/recherche/index.tsx                      |  76 +------------
 src/pages/sujet/[slug].tsx                         |  28 +----
 src/pages/thematique/[slug].tsx                    |  28 +----
 src/services/graphql/articles.query.ts             |   7 --
 src/services/graphql/articles.ts                   |   3 +-
 src/services/graphql/thematics.query.ts            |   9 --
 src/services/graphql/thematics.ts                  |   1 -
 src/services/graphql/topics.query.ts               |   9 --
 src/services/graphql/topics.ts                     |   1 -
 src/ts/types/app.ts                                |   5 +-
 src/ts/types/mdx.ts                                |   5 +-
 src/ts/types/raw-data.ts                           |   3 +-
 src/utils/helpers/pages.ts                         |  49 ++++++---
 38 files changed, 456 insertions(+), 971 deletions(-)
 create mode 100644 src/components/organisms/layout/comment.fixture.tsx
 create mode 100644 src/components/organisms/layout/comments-list.fixture.tsx
 create mode 100644 src/components/organisms/layout/posts-list.fixture.tsx
 create mode 100644 src/components/organisms/layout/summary.fixture.tsx

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx
index e465f76..68e364a 100644
--- a/src/components/molecules/layout/card.tsx
+++ b/src/components/molecules/layout/card.tsx
@@ -1,5 +1,6 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
+import { type Image } from '@ts/types/app';
 import { FC } from 'react';
 import ResponsiveImage, {
   type ResponsiveImageProps,
@@ -7,25 +8,6 @@ import ResponsiveImage, {
 import styles from './card.module.scss';
 import Meta, { type MetaData } from './meta';
 
-export type Cover = {
-  /**
-   * The cover alternative text.
-   */
-  alt: string;
-  /**
-   * The cover height.
-   */
-  height: number;
-  /**
-   * The cover source.
-   */
-  src: string;
-  /**
-   * The cover width.
-   */
-  width: number;
-};
-
 export type CardProps = {
   /**
    * Set additional classnames to the card wrapper.
@@ -34,7 +16,7 @@ export type CardProps = {
   /**
    * The card cover.
    */
-  cover?: Cover;
+  cover?: Image;
   /**
    * The cover fit. Default: cover.
    */
diff --git a/src/components/organisms/layout/cards-list.tsx b/src/components/organisms/layout/cards-list.tsx
index 0168bb2..1feddd0 100644
--- a/src/components/organisms/layout/cards-list.tsx
+++ b/src/components/organisms/layout/cards-list.tsx
@@ -10,32 +10,24 @@ export type CardsListItem = Omit<
   CardProps,
   'className' | 'coverFit' | 'titleLevel'
 > & {
-  id: string;
-};
-
-export type CardsListProps = {
-  /**
-   * Set additional classnames to the list wrapper.
-   */
-  className?: string;
-  /**
-   * The cover fit.
-   */
-  coverFit?: CardProps['coverFit'];
   /**
-   * The cards data.
+   * The card id.
    */
-  items: CardsListItem[];
-  /**
-   * The list kind. Either ordered or unordered.
-   */
-  kind?: ListProps['kind'];
-  /**
-   * The title level (hn).
-   */
-  titleLevel: CardProps['titleLevel'];
+  id: string;
 };
 
+export type CardsListProps = Pick<CardProps, 'coverFit' | 'titleLevel'> &
+  Pick<ListProps, 'kind'> & {
+    /**
+     * Set additional classnames to the list wrapper.
+     */
+    className?: string;
+    /**
+     * The cards data.
+     */
+    items: CardsListItem[];
+  };
+
 /**
  * CardsList component
  *
diff --git a/src/components/organisms/layout/comment.fixture.tsx b/src/components/organisms/layout/comment.fixture.tsx
new file mode 100644
index 0000000..0118139
--- /dev/null
+++ b/src/components/organisms/layout/comment.fixture.tsx
@@ -0,0 +1,41 @@
+import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates';
+import { CommentProps } from './comment';
+
+export const author = {
+  avatar: {
+    alt: 'Author avatar',
+    height: 480,
+    src: 'http://placeimg.com/640/480',
+    width: 640,
+  },
+  name: 'Armand',
+  website: 'https://www.armandphilippot.com/',
+};
+
+export const content =
+  'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.';
+
+export const date = '2021-04-03 23:04:24';
+
+export const meta = {
+  author,
+  date,
+};
+
+export const id = 5;
+
+export const saveComment = async () => {
+  /** Do nothing. */
+};
+
+export const data: CommentProps = {
+  approved: true,
+  content,
+  id,
+  meta,
+  parentId: 0,
+  saveComment,
+};
+
+export const formattedDate = getFormattedDate(date);
+export const formattedTime = getFormattedTime(date);
diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx
index fa4bba9..7a8ac95 100644
--- a/src/components/organisms/layout/comment.stories.tsx
+++ b/src/components/organisms/layout/comment.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import CommentComponent from './comment';
+import { data } from './comment.fixture';
 
 const saveComment = async () => {
   /** Do nothing. */
@@ -9,7 +10,7 @@ const saveComment = async () => {
  * Comment - Storybook Meta
  */
 export default {
-  title: 'Organisms/Layout',
+  title: 'Organisms/Layout/Comment',
   component: CommentComponent,
   args: {
     canReply: true,
@@ -110,17 +111,18 @@ const Template: ComponentStory<typeof CommentComponent> = (args) => (
 );
 
 /**
- * Layout Stories - Comment
+ * Layout Stories - Approved
  */
-export const Comment = Template.bind({});
-Comment.args = {
-  author: {
-    avatar: 'http://placeimg.com/640/480',
-    name: 'Armand',
-    url: 'https://www.armandphilippot.com/',
-  },
-  content:
-    'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.',
-  id: 2,
-  publication: '2021-04-03 23:04:24',
+export const Approved = Template.bind({});
+Approved.args = {
+  ...data,
+};
+
+/**
+ * Layout Stories - Unapproved
+ */
+export const Unapproved = Template.bind({});
+Unapproved.args = {
+  ...data,
+  approved: false,
 };
diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx
index 02a51dc..490a52b 100644
--- a/src/components/organisms/layout/comment.test.tsx
+++ b/src/components/organisms/layout/comment.test.tsx
@@ -1,48 +1,31 @@
 import { render, screen } from '@test-utils';
-import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates';
 import Comment from './comment';
-
-const author = {
-  avatar: 'http://placeimg.com/640/480',
-  name: 'Your name',
-  url: 'https://www.example.test/',
-};
-const content =
-  'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.';
-const publication = '2021-04-03 23:04:24';
-const id = 5;
-const saveComment = async () => {
-  /** Do nothing. */
-};
-const data = {
+import {
   author,
-  content,
+  data,
+  formattedDate,
+  formattedTime,
   id,
-  publication,
-  saveComment,
-};
-
-const formattedDate = getFormattedDate(publication);
-const formattedTime = getFormattedTime(publication);
+} from './comment.fixture';
 
 describe('Comment', () => {
   it('renders an avatar', () => {
-    render(<Comment {...data} />);
+    render(<Comment canReply={true} {...data} />);
     expect(
       screen.getByRole('img', { name: 'Your name avatar' })
     ).toBeInTheDocument();
   });
 
   it('renders the author website url', () => {
-    render(<Comment {...data} />);
+    render(<Comment canReply={true} {...data} />);
     expect(screen.getByRole('link', { name: author.name })).toHaveAttribute(
       'href',
-      author.url
+      author.website
     );
   });
 
   it('renders a permalink to the comment', () => {
-    render(<Comment {...data} />);
+    render(<Comment canReply={true} {...data} />);
     expect(
       screen.getByRole('link', {
         name: `${formattedDate} at ${formattedTime}`,
@@ -51,12 +34,12 @@ describe('Comment', () => {
   });
 
   it('renders a reply button', () => {
-    render(<Comment {...data} canReply={true} />);
+    render(<Comment canReply={true} {...data} />);
     expect(screen.getByRole('button', { name: 'Reply' })).toBeInTheDocument();
   });
 
   it('does not render a reply button', () => {
-    render(<Comment {...data} canReply={false} />);
+    render(<Comment canReply={false} {...data} />);
     expect(
       screen.queryByRole('button', { name: 'Reply' })
     ).not.toBeInTheDocument();
diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx
index 6df393b..1dc2c71 100644
--- a/src/components/organisms/layout/comment.tsx
+++ b/src/components/organisms/layout/comment.tsx
@@ -1,6 +1,7 @@
 import Button from '@components/atoms/buttons/button';
 import Link from '@components/atoms/links/link';
 import Meta from '@components/molecules/layout/meta';
+import { type Comment as CommentType } from '@ts/types/app';
 import useSettings from '@utils/hooks/use-settings';
 import Image from 'next/image';
 import Script from 'next/script';
@@ -25,32 +26,16 @@ export type CommentAuthor = {
   url?: string;
 };
 
-export type CommentProps = Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
-  /**
-   * The comment author data.
-   */
-  author: CommentAuthor;
-  /**
-   * Enable or disable the reply button. Default: true.
-   */
-  canReply?: boolean;
-  /**
-   * The comment body.
-   */
-  content: string;
-  /**
-   * The comment id.
-   */
-  id: number | string;
-  /**
-   * The comment parent id.
-   */
-  parentId?: number | string;
-  /**
-   * The comment date and time separated with a space.
-   */
-  publication: string;
-};
+export type CommentProps = Pick<
+  CommentType,
+  'approved' | 'content' | 'id' | 'meta' | 'parentId'
+> &
+  Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
+    /**
+     * Enable or disable the reply button. Default: true.
+     */
+    canReply?: boolean;
+  };
 
 /**
  * Comment component
@@ -58,19 +43,34 @@ export type CommentProps = Pick<CommentFormProps, 'Notice' | 'saveComment'> & {
  * Render a single comment.
  */
 const Comment: FC<CommentProps> = ({
-  author,
+  approved,
   canReply = true,
   content,
   id,
+  meta,
   Notice,
   parentId,
-  publication,
   saveComment,
   ...props
 }) => {
   const intl = useIntl();
+  const { website } = useSettings();
   const [isReplying, setIsReplying] = useState<boolean>(false);
-  const [publicationDate, publicationTime] = publication.split(' ');
+
+  if (!approved) {
+    return (
+      <div className={styles.wrapper}>
+        {intl.formatMessage({
+          defaultMessage: 'This comment is awaiting moderation...',
+          description: 'Comment: awaiting moderation',
+          id: '6a1Uo6',
+        })}
+      </div>
+    );
+  }
+
+  const { author, date } = meta;
+  const [publicationDate, publicationTime] = date.split(' ');
 
   const avatarAltText = intl.formatMessage(
     {
@@ -97,8 +97,6 @@ const Comment: FC<CommentProps> = ({
     id: '2fD5CI',
   });
 
-  const { website } = useSettings();
-
   const commentSchema: WithContext<CommentSchema> = {
     '@context': 'https://schema.org',
     '@id': `${website.url}/#comment-${id}`,
@@ -110,17 +108,17 @@ const Comment: FC<CommentProps> = ({
     author: {
       '@type': 'Person',
       name: author.name,
-      image: author.avatar,
-      url: author.url,
+      image: author.avatar?.src,
+      url: author.website,
     },
     creator: {
       '@type': 'Person',
       name: author.name,
-      image: author.avatar,
-      url: author.url,
+      image: author.avatar?.src,
+      url: author.website,
     },
-    dateCreated: publication,
-    datePublished: publication,
+    dateCreated: date,
+    datePublished: date,
     text: content,
   };
 
@@ -136,17 +134,19 @@ const Comment: FC<CommentProps> = ({
         className={`${styles.wrapper} ${styles['wrapper--comment']}`}
       >
         <header className={styles.header}>
-          <div className={styles.avatar}>
-            <Image
-              src={author.avatar}
-              alt={avatarAltText}
-              layout="fill"
-              objectFit="cover"
-              {...props}
-            />
-          </div>
-          {author.url ? (
-            <Link href={author.url} className={styles.author}>
+          {author.avatar && (
+            <div className={styles.avatar}>
+              <Image
+                src={author.avatar?.src}
+                alt={avatarAltText}
+                layout="fill"
+                objectFit="cover"
+                {...props}
+              />
+            </div>
+          )}
+          {author.website ? (
+            <Link href={author.website} className={styles.author}>
               {author.name}
             </Link>
           ) : (
@@ -181,7 +181,7 @@ const Comment: FC<CommentProps> = ({
       {isReplying && (
         <CommentForm
           Notice={Notice}
-          parentId={id as number}
+          parentId={id}
           saveComment={saveComment}
           title={formTitle}
           className={`${styles.wrapper} ${styles['wrapper--form']}`}
diff --git a/src/components/organisms/layout/comments-list.fixture.tsx b/src/components/organisms/layout/comments-list.fixture.tsx
new file mode 100644
index 0000000..2618f77
--- /dev/null
+++ b/src/components/organisms/layout/comments-list.fixture.tsx
@@ -0,0 +1,106 @@
+import { Comment } from '@ts/types/app';
+
+export const comments: Comment[] = [
+  {
+    approved: true,
+    content:
+      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
+    id: 1,
+    meta: {
+      author: {
+        avatar: {
+          alt: 'Author 1 avatar',
+          height: 480,
+          src: 'http://placeimg.com/640/480',
+          width: 640,
+        },
+        name: 'Author 1',
+      },
+      date: '2021-04-03 18:04:11',
+    },
+    parentId: 0,
+    replies: [],
+  },
+  {
+    approved: true,
+    content:
+      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
+    id: 2,
+    meta: {
+      author: {
+        avatar: {
+          alt: 'Author 2 avatar',
+          height: 480,
+          src: 'http://placeimg.com/640/480',
+          width: 640,
+        },
+        name: 'Author 2',
+        website: '#',
+      },
+      date: '2021-04-03 23:30:20',
+    },
+    parentId: 0,
+    replies: [
+      {
+        approved: true,
+        content:
+          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
+        id: 4,
+        meta: {
+          author: {
+            avatar: {
+              alt: 'Author 4 avatar',
+              height: 480,
+              src: 'http://placeimg.com/640/480',
+              width: 640,
+            },
+            name: 'Author 4',
+          },
+          date: '2021-04-03 23:04:24',
+        },
+        parentId: 2,
+        replies: [],
+      },
+      {
+        approved: true,
+        content:
+          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
+        id: 5,
+        meta: {
+          author: {
+            avatar: {
+              alt: 'Author 1 avatar',
+              height: 480,
+              src: 'http://placeimg.com/640/480',
+              width: 640,
+            },
+            name: 'Author 1',
+          },
+          date: '2021-04-04 08:05:14',
+        },
+        parentId: 2,
+        replies: [],
+      },
+    ],
+  },
+  {
+    approved: false,
+    content:
+      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
+    id: 3,
+    meta: {
+      author: {
+        avatar: {
+          alt: 'Author 3',
+          height: 480,
+          src: 'http://placeimg.com/640/480',
+          width: 640,
+        },
+        name: 'Author 3',
+      },
+      date: '2021-09-13 13:24:54',
+    },
+    parentId: 0,
+    replies: [],
+  },
+];
diff --git a/src/components/organisms/layout/comments-list.stories.tsx b/src/components/organisms/layout/comments-list.stories.tsx
index 22cc9d9..5ed0f2a 100644
--- a/src/components/organisms/layout/comments-list.stories.tsx
+++ b/src/components/organisms/layout/comments-list.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import CommentsListComponent, { type Comment } from './comments-list';
+import CommentsListComponent from './comments-list';
+import { comments } from './comments-list.fixture';
 
 const saveComment = async () => {
   /** Do nothing. */
@@ -71,62 +72,6 @@ const Template: ComponentStory<typeof CommentsListComponent> = (args) => (
   <CommentsListComponent {...args} />
 );
 
-const comments: Comment[] = [
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 1',
-    },
-    content:
-      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-    id: 1,
-    publication: '2021-04-03 18:04:11',
-  },
-  {
-    child: [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 4',
-        },
-        content:
-          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
-        id: 4,
-        publication: '2021-04-03 23:04:24',
-      },
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
-        id: 5,
-        publication: '2021-04-04 08:05:14',
-      },
-    ],
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 2',
-      url: '#',
-    },
-    content:
-      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
-    id: 2,
-    publication: '2021-04-03 23:30:20',
-  },
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 3',
-    },
-    content:
-      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
-    id: 3,
-    publication: '2021-09-13 13:24:54',
-  },
-];
-
 /**
  * Layout Stories - Without child comments
  */
diff --git a/src/components/organisms/layout/comments-list.test.tsx b/src/components/organisms/layout/comments-list.test.tsx
index e135ec9..b0a2467 100644
--- a/src/components/organisms/layout/comments-list.test.tsx
+++ b/src/components/organisms/layout/comments-list.test.tsx
@@ -1,65 +1,7 @@
 import { render } from '@test-utils';
-import CommentsList, { type Comment } from './comments-list';
-
-const comments: Comment[] = [
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 1',
-    },
-    content:
-      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-    id: 1,
-    publication: '2021-04-03 18:04:11',
-  },
-  {
-    child: [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 4',
-        },
-        content:
-          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
-        id: 4,
-        publication: '2021-04-03 23:04:24',
-      },
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
-        id: 5,
-        publication: '2021-04-04 08:05:14',
-      },
-    ],
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 2',
-      url: '#',
-    },
-    content:
-      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
-    id: 2,
-    publication: '2021-04-03 23:30:20',
-  },
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 3',
-    },
-    content:
-      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
-    id: 3,
-    publication: '2021-05-13 13:24:54',
-  },
-];
-
-const saveComment = async () => {
-  /** Do nothing. */
-};
+import { saveComment } from './comment.fixture';
+import CommentsList from './comments-list';
+import { comments } from './comments-list.fixture';
 
 describe('CommentsList', () => {
   it('renders a comments list', () => {
diff --git a/src/components/organisms/layout/comments-list.tsx b/src/components/organisms/layout/comments-list.tsx
index f04354c..97eccb7 100644
--- a/src/components/organisms/layout/comments-list.tsx
+++ b/src/components/organisms/layout/comments-list.tsx
@@ -1,13 +1,10 @@
 import SingleComment, {
   type CommentProps,
 } from '@components/organisms/layout/comment';
+import { Comment } from '@ts/types/app';
 import { FC } from 'react';
 import styles from './comments-list.module.scss';
 
-export type Comment = Omit<CommentProps, 'canReply' | 'saveComment'> & {
-  child?: Comment[];
-};
-
 export type CommentsListProps = Pick<CommentProps, 'Notice' | 'saveComment'> & {
   /**
    * An array of comments.
@@ -42,7 +39,7 @@ const CommentsList: FC<CommentsListProps> = ({
   ): JSX.Element[] => {
     const isLastLevel = startLevel === depth;
 
-    return commentsList.map(({ child, ...comment }) => (
+    return commentsList.map(({ replies, ...comment }) => (
       <li key={comment.id} className={styles.item}>
         <SingleComment
           canReply={!isLastLevel}
@@ -50,8 +47,8 @@ const CommentsList: FC<CommentsListProps> = ({
           saveComment={saveComment}
           {...comment}
         />
-        {child && !isLastLevel && (
-          <ol className={styles.list}>{getItems(child, startLevel + 1)}</ol>
+        {replies && !isLastLevel && (
+          <ol className={styles.list}>{getItems(replies, startLevel + 1)}</ol>
         )}
       </li>
     ));
diff --git a/src/components/organisms/layout/posts-list.fixture.tsx b/src/components/organisms/layout/posts-list.fixture.tsx
new file mode 100644
index 0000000..b0afdf8
--- /dev/null
+++ b/src/components/organisms/layout/posts-list.fixture.tsx
@@ -0,0 +1,61 @@
+import { type Post } from './posts-list';
+
+export const introPost1 =
+  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
+
+export const introPost2 =
+  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
+
+export const introPost3 =
+  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
+
+export const cover = {
+  alt: 'cover',
+  height: 480,
+  src: 'http://placeimg.com/640/480',
+  width: 640,
+};
+
+export const posts: Post[] = [
+  {
+    intro: introPost1,
+    id: 'post-1',
+    meta: {
+      cover,
+      dates: { publication: '2022-02-26' },
+      wordsCount: introPost1.split(' ').length,
+      thematics: [
+        { id: 1, name: 'Cat 1', slug: '#' },
+        { id: 2, name: 'Cat 2', slug: '#' },
+      ],
+      commentsCount: 1,
+    },
+    title: 'Ratione velit fuga',
+    url: '#',
+  },
+  {
+    intro: introPost2,
+    id: 'post-2',
+    meta: {
+      dates: { publication: '2022-02-20' },
+      wordsCount: introPost2.split(' ').length,
+      thematics: [{ id: 2, name: 'Cat 2', slug: '#' }],
+      commentsCount: 0,
+    },
+    title: 'Debitis laudantium laudantium',
+    url: '#',
+  },
+  {
+    intro: introPost3,
+    id: 'post-3',
+    meta: {
+      cover,
+      dates: { publication: '2021-12-20' },
+      wordsCount: introPost3.split(' ').length,
+      thematics: [{ id: 1, name: 'Cat 1', slug: '#' }],
+      commentsCount: 3,
+    },
+    title: 'Quaerat ut corporis',
+    url: '#',
+  },
+];
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index 360d441..68e3dc0 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
-import PostsList, { type Post } from './posts-list';
+import PostsList from './posts-list';
+import { posts } from './posts-list.fixture';
 
 /**
  * PostsList - Storybook Meta
@@ -154,67 +155,6 @@ const Template: ComponentStory<typeof PostsList> = (args) => (
   <PostsList {...args} />
 );
 
-const excerpt1 =
-  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
-const excerpt2 =
-  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
-const excerpt3 =
-  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
-
-const posts: Post[] = [
-  {
-    excerpt: excerpt1,
-    id: 'post-1',
-    meta: {
-      dates: { publication: '2022-02-26' },
-      readingTime: { wordsCount: excerpt1.split(' ').length },
-      thematics: [
-        { id: 'cat-1', name: 'Cat 1', url: '#' },
-        { id: 'cat-2', name: 'Cat 2', url: '#' },
-      ],
-      commentsCount: 1,
-    },
-    title: 'Ratione velit fuga',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-  {
-    excerpt: excerpt2,
-    id: 'post-2',
-    meta: {
-      dates: { publication: '2022-02-20' },
-      readingTime: { wordsCount: excerpt2.split(' ').length },
-      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
-      commentsCount: 0,
-    },
-    title: 'Debitis laudantium laudantium',
-    url: '#',
-  },
-  {
-    excerpt: excerpt3,
-    id: 'post-3',
-    meta: {
-      dates: { publication: '2021-12-20' },
-      readingTime: { wordsCount: excerpt3.split(' ').length },
-      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
-      commentsCount: 3,
-    },
-    title: 'Quaerat ut corporis',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-];
-
 /**
  * PostsList Stories - Default
  */
diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx
index dfb9825..1bab466 100644
--- a/src/components/organisms/layout/posts-list.test.tsx
+++ b/src/components/organisms/layout/posts-list.test.tsx
@@ -1,66 +1,6 @@
 import { render, screen } from '@test-utils';
-import PostsList, { Post } from './posts-list';
-
-const excerpt1 =
-  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
-const excerpt2 =
-  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
-const excerpt3 =
-  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
-
-const posts: Post[] = [
-  {
-    excerpt: excerpt1,
-    id: 'post-1',
-    meta: {
-      dates: { publication: '2022-02-26' },
-      readingTime: { wordsCount: excerpt1.split(' ').length },
-      thematics: [
-        { id: 'cat-1', name: 'Cat 1', url: '#' },
-        { id: 'cat-2', name: 'Cat 2', url: '#' },
-      ],
-      commentsCount: 1,
-    },
-    title: 'Ratione velit fuga',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-  {
-    excerpt: excerpt2,
-    id: 'post-2',
-    meta: {
-      dates: { publication: '2022-02-20' },
-      readingTime: { wordsCount: excerpt2.split(' ').length },
-      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
-      commentsCount: 0,
-    },
-    title: 'Debitis laudantium laudantium',
-    url: '#',
-  },
-  {
-    excerpt: excerpt3,
-    id: 'post-3',
-    meta: {
-      dates: { publication: '2021-12-20' },
-      readingTime: { wordsCount: excerpt3.split(' ').length },
-      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
-      commentsCount: 3,
-    },
-    title: 'Quaerat ut corporis',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-];
+import PostsList from './posts-list';
+import { posts } from './posts-list.fixture';
 
 describe('PostsList', () => {
   it('renders the correct number of posts', () => {
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index 608130e..91fc62d 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -3,7 +3,7 @@ import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
 import ProgressBar from '@components/atoms/loaders/progress-bar';
 import Spinner from '@components/atoms/loaders/spinner';
 import Pagination, {
-  PaginationProps,
+  type PaginationProps,
 } from '@components/molecules/nav/pagination';
 import useIsMounted from '@utils/hooks/use-is-mounted';
 import useSettings from '@utils/hooks/use-settings';
@@ -12,7 +12,7 @@ import { useIntl } from 'react-intl';
 import styles from './posts-list.module.scss';
 import Summary, { type SummaryProps } from './summary';
 
-export type Post = SummaryProps & {
+export type Post = Omit<SummaryProps, 'titleLevel'> & {
   /**
    * The post id.
    */
diff --git a/src/components/organisms/layout/summary.fixture.tsx b/src/components/organisms/layout/summary.fixture.tsx
new file mode 100644
index 0000000..fbc46a5
--- /dev/null
+++ b/src/components/organisms/layout/summary.fixture.tsx
@@ -0,0 +1,25 @@
+import { type SummaryMeta } from './summary';
+
+export const cover = {
+  alt: 'A cover',
+  height: 480,
+  src: 'http://placeimg.com/640/480',
+  width: 640,
+};
+
+export const intro =
+  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
+
+export const meta: SummaryMeta = {
+  dates: { publication: '2022-04-11' },
+  wordsCount: intro.split(' ').length,
+  thematics: [
+    { id: 1, name: 'Cat 1', slug: '#' },
+    { id: 2, name: 'Cat 2', slug: '#' },
+  ],
+  commentsCount: 1,
+};
+
+export const title = 'Odio odit necessitatibus';
+
+export const url = '#';
diff --git a/src/components/organisms/layout/summary.stories.tsx b/src/components/organisms/layout/summary.stories.tsx
index 2512051..0b91e24 100644
--- a/src/components/organisms/layout/summary.stories.tsx
+++ b/src/components/organisms/layout/summary.stories.tsx
@@ -1,5 +1,6 @@
 import { ComponentMeta, ComponentStory } from '@storybook/react';
 import Summary from './summary';
+import { cover, intro, meta } from './summary.fixture';
 
 /**
  * Summary - Storybook Meta
@@ -83,32 +84,12 @@ const Template: ComponentStory<typeof Summary> = (args) => (
   <Summary {...args} />
 );
 
-const cover = {
-  alt: 'A cover',
-  height: 480,
-  src: 'http://placeimg.com/640/480',
-  width: 640,
-};
-
-const excerpt =
-  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
-
-const meta = {
-  dates: { publication: '2022-04-11' },
-  readingTime: { wordsCount: excerpt.split(' ').length },
-  thematics: [
-    { id: 'cat-1', name: 'Cat 1', url: '#' },
-    { id: 'cat-2', name: 'Cat 2', url: '#' },
-  ],
-  commentsCount: 1,
-};
-
 /**
  * Summary Stories - Default
  */
 export const Default = Template.bind({});
 Default.args = {
-  excerpt,
+  intro,
   meta,
   title: 'Odio odit necessitatibus',
   url: '#',
@@ -119,9 +100,8 @@ Default.args = {
  */
 export const WithCover = Template.bind({});
 WithCover.args = {
-  cover,
-  excerpt,
-  meta,
+  intro,
+  meta: { ...meta, cover },
   title: 'Odio odit necessitatibus',
   url: '#',
 };
diff --git a/src/components/organisms/layout/summary.test.tsx b/src/components/organisms/layout/summary.test.tsx
index 9e34254..7617c26 100644
--- a/src/components/organisms/layout/summary.test.tsx
+++ b/src/components/organisms/layout/summary.test.tsx
@@ -1,35 +1,12 @@
 import { render, screen } from '@test-utils';
 import Summary from './summary';
-
-const cover = {
-  alt: 'A cover',
-  height: 480,
-  src: 'http://placeimg.com/640/480',
-  width: 640,
-};
-
-const excerpt =
-  'Perspiciatis quasi libero nemo non eligendi nam minima. Deleniti expedita tempore. Praesentium explicabo molestiae eaque consectetur vero. Quae nostrum quisquam similique. Ut hic est quas ut esse quisquam nobis.';
-
-const meta = {
-  dates: { publication: '2022-04-11' },
-  readingTime: { wordsCount: excerpt.split(' ').length },
-  thematics: [
-    { id: 'cat-1', name: 'Cat 1', url: '#' },
-    { id: 'cat-2', name: 'Cat 2', url: '#' },
-  ],
-  commentsCount: 1,
-};
-
-const title = 'Odio odit necessitatibus';
-
-const url = '#';
+import { cover, intro, meta, title, url } from './summary.fixture';
 
 describe('Summary', () => {
   it('renders a title wrapped in a h2 element', () => {
     render(
       <Summary
-        excerpt={excerpt}
+        intro={intro}
         meta={meta}
         title={title}
         titleLevel={2}
@@ -42,16 +19,15 @@ describe('Summary', () => {
   });
 
   it('renders an excerpt', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
-    expect(screen.getByText(excerpt)).toBeInTheDocument();
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
+    expect(screen.getByText(intro)).toBeInTheDocument();
   });
 
   it('renders a cover', () => {
     render(
       <Summary
-        cover={cover}
-        excerpt={excerpt}
-        meta={meta}
+        intro={intro}
+        meta={{ ...meta, cover }}
         title={title}
         url={url}
       />
@@ -60,19 +36,19 @@ describe('Summary', () => {
   });
 
   it('renders a link to the full post', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
     expect(screen.getByRole('link', { name: title })).toBeInTheDocument();
   });
 
   it('renders a read more link', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
     expect(
       screen.getByRole('link', { name: `Read more about ${title}` })
     ).toBeInTheDocument();
   });
 
   it('renders some meta', () => {
-    render(<Summary excerpt={excerpt} meta={meta} title={title} url={url} />);
-    expect(screen.getByText(meta.thematics[0].name)).toBeInTheDocument();
+    render(<Summary intro={intro} meta={meta} title={title} url={url} />);
+    expect(screen.getByText(meta.thematics![0].name)).toBeInTheDocument();
   });
 });
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index 3831c0c..ca51cd6 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -6,7 +6,7 @@ import ResponsiveImage, {
   type ResponsiveImageProps,
 } from '@components/molecules/images/responsive-image';
 import Meta, { type MetaData } from '@components/molecules/layout/meta';
-import { type Dates } from '@ts/types/app';
+import { type Article, type Meta as MetaType } from '@ts/types/app';
 import useReadingTime from '@utils/hooks/use-reading-time';
 import { FC, ReactNode } from 'react';
 import { useIntl } from 'react-intl';
@@ -17,43 +17,22 @@ export type Cover = Pick<
   'alt' | 'src' | 'width' | 'height'
 >;
 
-export type SummaryMetaLink = {
-  id: number | string;
-  name: string;
-  url: string;
-};
-
-export type SummaryMetaReadingTime = {
-  wordsCount: number;
-  onlyMinutes?: boolean;
-};
-
-export type SummaryMeta = {
-  author?: string;
-  commentsCount?: number;
-  dates: Dates;
-  readingTime: SummaryMetaReadingTime;
-  thematics?: SummaryMetaLink[];
-  topics?: SummaryMetaLink[];
-};
+export type SummaryMeta = Pick<
+  MetaType<'article'>,
+  | 'author'
+  | 'commentsCount'
+  | 'cover'
+  | 'dates'
+  | 'thematics'
+  | 'topics'
+  | 'wordsCount'
+>;
 
-export type SummaryProps = {
+export type SummaryProps = Pick<Article, 'intro' | 'title'> & {
   /**
-   * The post cover.
-   */
-  cover?: Cover;
-  /**
-   * The post excerpt.
-   */
-  excerpt: string;
-  /**
-   * The post meta.
+   * The post metadata.
    */
   meta: SummaryMeta;
-  /**
-   * The post title.
-   */
-  title: string;
   /**
    * The heading level (hn).
    */
@@ -70,8 +49,7 @@ export type SummaryProps = {
  * Render a page summary.
  */
 const Summary: FC<SummaryProps> = ({
-  cover,
-  excerpt,
+  intro,
   meta,
   title,
   titleLevel = 2,
@@ -91,14 +69,13 @@ const Summary: FC<SummaryProps> = ({
       ),
     }
   );
-  const { wordsCount, onlyMinutes } = meta.readingTime;
-  const readingTime = useReadingTime(wordsCount, onlyMinutes);
-
-  const getMeta = (data: SummaryMeta): MetaData => {
-    const { author, commentsCount, dates, thematics, topics } = data;
+  const { author, commentsCount, cover, dates, thematics, topics, wordsCount } =
+    meta;
+  const readingTime = useReadingTime(wordsCount, true);
 
+  const getMeta = (): MetaData => {
     return {
-      author,
+      author: author?.name,
       publication: { date: dates.publication },
       update:
         dates.update && dates.publication !== dates.update
@@ -106,12 +83,12 @@ const Summary: FC<SummaryProps> = ({
           : undefined,
       readingTime,
       thematics: thematics?.map((thematic) => (
-        <Link key={thematic.id} href={thematic.url}>
+        <Link key={thematic.id} href={thematic.slug}>
           {thematic.name}
         </Link>
       )),
       topics: topics?.map((topic) => (
-        <Link key={topic.id} href={topic.url}>
+        <Link key={topic.id} href={topic.slug}>
           {topic.name}
         </Link>
       )),
@@ -134,11 +111,7 @@ const Summary: FC<SummaryProps> = ({
         </Link>
       </header>
       <div className={styles.body}>
-        {typeof excerpt === 'string' ? (
-          <div dangerouslySetInnerHTML={{ __html: excerpt }} />
-        ) : (
-          excerpt
-        )}
+        <div dangerouslySetInnerHTML={{ __html: intro }} />
         <ButtonLink target={url} className={styles['read-more']}>
           <>
             {readMore}
@@ -148,7 +121,7 @@ const Summary: FC<SummaryProps> = ({
       </div>
       <footer className={styles.footer}>
         <Meta
-          data={getMeta(meta)}
+          data={getMeta()}
           layout="column"
           itemsLayout="stacked"
           withSeparator={false}
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
index bc1d7b6..88e3785 100644
--- a/src/components/templates/page/page-layout.stories.tsx
+++ b/src/components/templates/page/page-layout.stories.tsx
@@ -1,7 +1,9 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Heading from '@components/atoms/headings/heading';
 import Link from '@components/atoms/links/link';
+import { comments } from '@components/organisms/layout/comments-list.fixture';
 import PostsList from '@components/organisms/layout/posts-list';
+import { posts } from '@components/organisms/layout/posts-list.fixture';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import Sharing from '@components/organisms/widgets/sharing';
 import { ComponentMeta, ComponentStory } from '@storybook/react';
@@ -267,62 +269,6 @@ const postBreadcrumb = [
   { id: 'post', url: '#', name: pageTitle },
 ];
 
-const comments = [
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 1',
-    },
-    content:
-      'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-    id: 1,
-    publication: '2021-04-03 18:04:11',
-  },
-  {
-    child: [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 4',
-        },
-        content:
-          'Vel ullam in porro tempore. Maiores quos quia magnam beatae nemo libero velit numquam. Sapiente aliquid cumque. Velit neque in adipisci aut assumenda voluptates earum. Autem esse autem provident in tempore. Aut distinctio dolor qui repellat et et adipisci velit aspernatur.',
-        id: 4,
-        publication: '2021-04-03 23:04:24',
-      },
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Sed non omnis. Quam porro est. Quae tempore quae. Exercitationem eos non velit voluptatem velit voluptas iusto. Sit debitis qui ipsam quo asperiores numquam veniam praesentium ut.',
-        id: 5,
-        publication: '2021-04-04 08:05:14',
-      },
-    ],
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 2',
-      url: '#',
-    },
-    content:
-      'Sit sed error quasi voluptatem velit voluptas aut. Aut debitis eveniet. Praesentium dolores quia voluptate vero quis dicta quasi vel. Aut voluptas accusantium ut aut quidem consectetur itaque laboriosam occaecati.',
-    id: 2,
-    publication: '2021-04-03 23:30:20',
-  },
-  {
-    author: {
-      avatar: 'http://placeimg.com/640/480',
-      name: 'Author 3',
-    },
-    content:
-      'Natus consequatur maiores aperiam dolore eius nesciunt ut qui et. Ab ea nobis est. Eaque dolor corrupti id aut. Impedit architecto autem qui neque rerum ab dicta dignissimos voluptates.',
-    id: 3,
-    publication: '2021-09-13 13:24:54',
-  },
-];
-
 /**
  * Page Layout Stories - Post
  */
@@ -393,7 +339,7 @@ Post.args = {
     />,
   ],
   withToC: true,
-  comments,
+  comments: comments,
   allowComments: true,
 };
 
@@ -402,67 +348,6 @@ const postsListBreadcrumb = [
   { id: 'blog', url: '#', name: 'Blog' },
 ];
 
-const excerpt1 =
-  'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.';
-const excerpt2 =
-  'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.';
-const excerpt3 =
-  'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.';
-
-const posts = [
-  {
-    excerpt: excerpt1,
-    id: 'post-1',
-    meta: {
-      dates: { publication: '2022-02-26' },
-      readingTime: { wordsCount: excerpt1.split(' ').length },
-      thematics: [
-        { id: 'cat-1', name: 'Cat 1', url: '#' },
-        { id: 'cat-2', name: 'Cat 2', url: '#' },
-      ],
-      commentsCount: 1,
-    },
-    title: 'Ratione velit fuga',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-  {
-    excerpt: excerpt2,
-    id: 'post-2',
-    meta: {
-      dates: { publication: '2022-02-20' },
-      readingTime: { wordsCount: excerpt2.split(' ').length },
-      thematics: [{ id: 'cat-2', name: 'Cat 2', url: '#' }],
-      commentsCount: 0,
-    },
-    title: 'Debitis laudantium laudantium',
-    url: '#',
-  },
-  {
-    excerpt: excerpt3,
-    id: 'post-3',
-    meta: {
-      dates: { publication: '2021-12-20' },
-      readingTime: { wordsCount: excerpt3.split(' ').length },
-      thematics: [{ id: 'cat-1', name: 'Cat 1', url: '#' }],
-      commentsCount: 3,
-    },
-    title: 'Quaerat ut corporis',
-    url: '#',
-    cover: {
-      alt: 'cover',
-      height: 480,
-      src: 'http://placeimg.com/640/480',
-      width: 640,
-    },
-  },
-];
-
 const blogCategories = [
   { name: 'Cat 1', url: '#' },
   {
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
index a0c8923..f2d07d7 100644
--- a/src/components/templates/page/page-layout.test.tsx
+++ b/src/components/templates/page/page-layout.test.tsx
@@ -1,3 +1,4 @@
+import { comments } from '@components/organisms/layout/comments-list.fixture';
 import { render, screen } from '@test-utils';
 import { BreadcrumbList } from 'schema-dts';
 import PageLayout from './page-layout';
@@ -88,18 +89,6 @@ describe('PageLayout', () => {
   });
 
   it('renders the comments list', () => {
-    const comments = [
-      {
-        author: {
-          avatar: 'http://placeimg.com/640/480',
-          name: 'Author 1',
-        },
-        content:
-          'Voluptas ducimus inventore. Libero ut et doloribus. Earum nostrum ab. Aliquam rem dolores omnis voluptate. Sunt aut ut et.',
-        id: 1,
-        publication: '2021-04-03 18:04:11',
-      },
-    ];
     render(
       <PageLayout
         breadcrumb={breadcrumb}
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 812fffe..2878538 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -18,11 +18,8 @@ import {
   type NextPageWithLayout,
 } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
-import useAddClassName from '@utils/hooks/use-add-classname';
-import useAttributes from '@utils/hooks/use-attributes';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePrism, { type OptionalPrismPlugin } from '@utils/hooks/use-prism';
-import useQuerySelectorAll from '@utils/hooks/use-query-selector-all';
 import useReadingTime from '@utils/hooks/use-reading-time';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps } from 'next';
@@ -66,12 +63,15 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     title,
     url: `/article/${slug}`,
   });
-  const readingTime = useReadingTime(wordsCount || 0, true);
+  const readingTime = useReadingTime(wordsCount, true);
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     author: author?.name,
     publication: { date: dates.publication },
-    update: dates.update ? { date: dates.update } : undefined,
+    update:
+      dates.update && dates.publication !== dates.update
+        ? { date: dates.update }
+        : undefined,
     readingTime,
     thematics:
       thematics &&
@@ -167,35 +167,6 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     '@graph': [webpageSchema, blogSchema, blogPostSchema],
   };
 
-  /**
-   * Convert the comments list to the right format.
-   *
-   * @param {Comment[]} list - The comments list.
-   * @returns {PageLayoutProps['comments']} - The formatted comments list.
-   */
-  const getCommentsList = (list: Comment[]): PageLayoutProps['comments'] => {
-    return list.map((comment) => {
-      const {
-        content: commentBody,
-        id: commentId,
-        meta: commentMeta,
-        parentId,
-        replies,
-      } = comment;
-      const { author: commentAuthor, date } = commentMeta;
-      const { name, avatar, website: authorUrl } = commentAuthor;
-
-      return {
-        author: { name, avatar: avatar!.src, url: authorUrl },
-        content: commentBody,
-        id: commentId,
-        publication: date,
-        child: getCommentsList(replies),
-        parentId,
-      };
-    });
-  };
-
   const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers'];
   const { attributes, className } = usePrism({ plugins: prismPlugins });
   const lineNumbersClassName = className
@@ -254,7 +225,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
         bodyClassName={styles.body}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        comments={data && getCommentsList(data)}
+        comments={data}
         footerMeta={footerMeta}
         headerMeta={headerMeta}
         id={id as number}
@@ -314,7 +285,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
 
   return {
     paths,
-    fallback: true,
+    fallback: false,
   };
 };
 
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index 90f56be..dd04fad 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,34 +1,27 @@
 import Notice from '@components/atoms/layout/notice';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
-import {
-  getArticleFromRawData,
-  getArticles,
-  getTotalArticles,
-} from '@services/graphql/articles';
+import { getArticles, getTotalArticles } from '@services/graphql/articles';
 import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
-  type Article,
-  type Meta,
-  type NextPageWithLayout,
-} from '@ts/types/app';
-import {
-  RawThematicPreview,
-  RawTopicPreview,
   type RawArticle,
+  type RawThematicPreview,
+  type RawTopicPreview,
 } from '@ts/types/raw-data';
 import { settings } from '@utils/config';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
+  getPostsList,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
@@ -119,62 +112,6 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
     '@graph': [webpageSchema, blogSchema],
   };
 
-  /**
-   * Retrieve the formatted meta.
-   *
-   * @param {Meta<'article'>} meta - The article meta.
-   * @returns {Post['meta']} The formatted meta.
-   */
-  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-    const { commentsCount, dates, thematics, wordsCount } = meta;
-
-    return {
-      commentsCount,
-      dates,
-      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-      thematics: thematics?.map((thematic) => {
-        return { ...thematic, url: `/thematique/${thematic.slug}` };
-      }),
-    };
-  };
-
-  /**
-   * Retrieve the formatted posts.
-   *
-   * @param {Article[]} posts - An array of articles.
-   * @returns {Post[]} An array of formatted posts.
-   */
-  const getPosts = (posts: Article[]): Post[] => {
-    return posts.map((post) => {
-      return {
-        ...post,
-        cover: post.meta.cover,
-        excerpt: post.intro,
-        meta: getPostMeta(post.meta),
-        url: `/article/${post.slug}`,
-      };
-    });
-  };
-
-  /**
-   * Retrieve the posts list from raw data.
-   *
-   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
-   * @returns {Post[]} An array of posts.
-   */
-  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
-    const articlesList: RawArticle[] = [];
-    rawData.forEach((articleData) =>
-      articleData.edges.forEach((edge) => {
-        articlesList.push(edge.node);
-      })
-    );
-
-    return getPosts(
-      articlesList.map((article) => getArticleFromRawData(article))
-    );
-  };
-
   const {
     data,
     error,
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index e8c93f7..8f50ddd 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -1,10 +1,9 @@
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { type EdgesResponse } from '@services/graphql/api';
 import {
-  getArticleFromRawData,
   getArticles,
   getArticlesEndCursor,
   getTotalArticles,
@@ -14,11 +13,7 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
-import {
-  type Article,
-  type Meta,
-  type NextPageWithLayout,
-} from '@ts/types/app';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
   type RawArticle,
   type RawThematicPreview,
@@ -29,6 +24,7 @@ import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
+  getPostsList,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useRedirection from '@utils/hooks/use-redirection';
@@ -131,62 +127,6 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
     '@graph': [webpageSchema, blogSchema],
   };
 
-  /**
-   * Retrieve the formatted meta.
-   *
-   * @param {Meta<'article'>} meta - The article meta.
-   * @returns {Post['meta']} The formatted meta.
-   */
-  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-    const { commentsCount, dates, thematics, wordsCount } = meta;
-
-    return {
-      commentsCount,
-      dates,
-      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-      thematics: thematics?.map((thematic) => {
-        return { ...thematic, url: `/thematique/${thematic.slug}` };
-      }),
-    };
-  };
-
-  /**
-   * Retrieve the formatted posts.
-   *
-   * @param {Article[]} posts - An array of articles.
-   * @returns {Post[]} An array of formatted posts.
-   */
-  const getPosts = (posts: Article[]): Post[] => {
-    return posts.map((post) => {
-      return {
-        ...post,
-        cover: post.meta.cover,
-        excerpt: post.intro,
-        meta: getPostMeta(post.meta),
-        url: `/article/${post.slug}`,
-      };
-    });
-  };
-
-  /**
-   * Retrieve the posts list from raw data.
-   *
-   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
-   * @returns {Post[]} An array of posts.
-   */
-  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
-    const articlesList: RawArticle[] = [];
-    rawData.forEach((articleData) =>
-      articleData.edges.forEach((edge) => {
-        articlesList.push(edge.node);
-      })
-    );
-
-    return getPosts(
-      articlesList.map((article) => getArticleFromRawData(article))
-    );
-  };
-
   const thematicsListTitle = intl.formatMessage({
     defaultMessage: 'Thematics',
     description: 'BlogPage: thematics list widget title',
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index e9b36fa..27c715d 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -1,14 +1,18 @@
 import Link from '@components/atoms/links/link';
-import SocialLink, { SocialWebsite } from '@components/atoms/links/social-link';
+import SocialLink, {
+  type SocialWebsite,
+} from '@components/atoms/links/social-link';
 import Spinner from '@components/atoms/loaders/spinner';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Code from '@components/molecules/layout/code';
 import Gallery from '@components/organisms/images/gallery';
-import Overview, { OverviewMeta } from '@components/organisms/layout/overview';
+import Overview, {
+  type OverviewMeta,
+} from '@components/organisms/layout/overview';
 import Sharing from '@components/organisms/widgets/sharing';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
-  PageLayoutProps,
+  type PageLayoutProps,
 } from '@components/templates/page/page-layout';
 import styles from '@styles/pages/project.module.scss';
 import {
@@ -16,11 +20,11 @@ import {
   type ProjectPreview,
   type Repos,
 } from '@ts/types/app';
-import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
 import { capitalize } from '@utils/helpers/strings';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
-import useGithubApi, { RepoData } from '@utils/hooks/use-github-api';
+import useGithubApi, { type RepoData } from '@utils/hooks/use-github-api';
 import useSettings from '@utils/hooks/use-settings';
 import { MDXComponents, NestedMDXComponents } from 'mdx/types';
 import { GetStaticPaths, GetStaticProps } from 'next';
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index d500b6b..9ca289a 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -1,13 +1,13 @@
 import Link from '@components/atoms/links/link';
 import CardsList, {
-  CardsListItem,
+  type CardsListItem,
 } from '@components/organisms/layout/cards-list';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import PageContent, { meta } from '@content/pages/projects.mdx';
 import styles from '@styles/pages/projects.module.scss';
 import { type NextPageWithLayout, type ProjectCard } from '@ts/types/app';
-import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import { getProjectsCard } from '@utils/helpers/projects';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index 8895015..c69d931 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -1,34 +1,26 @@
 import Notice from '@components/atoms/layout/notice';
 import Spinner from '@components/atoms/loaders/spinner';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
-import { type EdgesResponse } from '@services/graphql/api';
-import {
-  getArticleFromRawData,
-  getArticles,
-  getTotalArticles,
-} from '@services/graphql/articles';
+import { getArticles, getTotalArticles } from '@services/graphql/articles';
 import {
   getThematicsPreview,
   getTotalThematics,
 } from '@services/graphql/thematics';
 import { getTopicsPreview, getTotalTopics } from '@services/graphql/topics';
+import { type NextPageWithLayout } from '@ts/types/app';
 import {
-  type Article,
-  type Meta,
-  type NextPageWithLayout,
-} from '@ts/types/app';
-import {
-  RawThematicPreview,
-  RawTopicPreview,
   type RawArticle,
+  type RawThematicPreview,
+  type RawTopicPreview,
 } from '@ts/types/raw-data';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
+  getPostsList,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
@@ -145,62 +137,6 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
     getTotalArticles(query.s as string)
   );
 
-  /**
-   * Retrieve the formatted meta.
-   *
-   * @param {Meta<'article'>} meta - The article meta.
-   * @returns {Post['meta']} The formatted meta.
-   */
-  const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-    const { commentsCount, dates, thematics, wordsCount } = meta;
-
-    return {
-      commentsCount,
-      dates,
-      readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-      thematics: thematics?.map((thematic) => {
-        return { ...thematic, url: `/thematique/${thematic.slug}` };
-      }),
-    };
-  };
-
-  /**
-   * Retrieve the formatted posts.
-   *
-   * @param {Article[]} posts - An array of articles.
-   * @returns {Post[]} An array of formatted posts.
-   */
-  const getPosts = (posts: Article[]): Post[] => {
-    return posts.map((post) => {
-      return {
-        ...post,
-        cover: post.meta.cover,
-        excerpt: post.intro,
-        meta: getPostMeta(post.meta),
-        url: `/article/${post.slug}`,
-      };
-    });
-  };
-
-  /**
-   * Retrieve the posts list from raw data.
-   *
-   * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
-   * @returns {Post[]} An array of posts.
-   */
-  const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
-    const articlesList: RawArticle[] = [];
-    rawData.forEach((articleData) =>
-      articleData.edges.forEach((edge) => {
-        articlesList.push(edge.node);
-      })
-    );
-
-    return getPosts(
-      articlesList.map((article) => getArticleFromRawData(article))
-    );
-  };
-
   /**
    * Load more posts handler.
    */
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index b4845eb..95843cd 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -1,6 +1,6 @@
 import Heading from '@components/atoms/headings/heading';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
@@ -14,7 +14,6 @@ import {
 } from '@services/graphql/topics';
 import styles from '@styles/pages/topic.module.scss';
 import {
-  type Article,
   type NextPageWithLayout,
   type PageLink,
   type Topic,
@@ -23,7 +22,7 @@ import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
-  getPostMeta,
+  getPostsWithUrl,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
@@ -109,27 +108,6 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
     '@graph': [webpageSchema, articleSchema],
   };
 
-  const getPosts = (array: Article[]): Post[] => {
-    return array.map((article) => {
-      const {
-        intro: articleIntro,
-        meta: articleMeta,
-        slug: articleSlug,
-        ...remainingData
-      } = article;
-
-      const { cover: articleCover, ...remainingMeta } = articleMeta;
-
-      return {
-        cover: articleCover,
-        excerpt: articleIntro,
-        meta: getPostMeta(remainingMeta),
-        url: `/article/${articleSlug}`,
-        ...remainingData,
-      };
-    });
-  };
-
   const topicsListTitle = intl.formatMessage({
     defaultMessage: 'Other topics',
     description: 'TopicPage: other topics list widget title',
@@ -206,7 +184,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
             </Heading>
             <PostsList
               baseUrl="/sujet/page/"
-              posts={getPosts(articles)}
+              posts={getPostsWithUrl(articles)}
               total={articles.length}
               titleLevel={3}
               byYear={true}
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index ce4eccf..a91483e 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -1,5 +1,5 @@
 import Heading from '@components/atoms/headings/heading';
-import PostsList, { type Post } from '@components/organisms/layout/posts-list';
+import PostsList from '@components/organisms/layout/posts-list';
 import LinksListWidget from '@components/organisms/widgets/links-list-widget';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout, {
@@ -12,7 +12,6 @@ import {
   getTotalThematics,
 } from '@services/graphql/thematics';
 import {
-  type Article,
   type NextPageWithLayout,
   type PageLink,
   type Thematic,
@@ -21,7 +20,7 @@ import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import {
   getLinksListItems,
   getPageLinkFromRawData,
-  getPostMeta,
+  getPostsWithUrl,
 } from '@utils/helpers/pages';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
@@ -99,27 +98,6 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
     '@graph': [webpageSchema, articleSchema],
   };
 
-  const getPosts = (array: Article[]): Post[] => {
-    return array.map((article) => {
-      const {
-        intro: articleIntro,
-        meta: articleMeta,
-        slug: articleSlug,
-        ...remainingData
-      } = article;
-
-      const { cover, ...remainingMeta } = articleMeta;
-
-      return {
-        cover,
-        excerpt: articleIntro,
-        meta: getPostMeta(remainingMeta),
-        url: `/article/${articleSlug}`,
-        ...remainingData,
-      };
-    });
-  };
-
   const thematicsListTitle = intl.formatMessage({
     defaultMessage: 'Other thematics',
     description: 'ThematicPage: other thematics list widget title',
@@ -187,7 +165,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
             </Heading>
             <PostsList
               baseUrl="/thematique/page/"
-              posts={getPosts(articles)}
+              posts={getPostsWithUrl(articles)}
               total={articles.length}
               titleLevel={3}
               byYear={true}
diff --git a/src/services/graphql/articles.query.ts b/src/services/graphql/articles.query.ts
index 7bd0901..3e1f575 100644
--- a/src/services/graphql/articles.query.ts
+++ b/src/services/graphql/articles.query.ts
@@ -56,7 +56,6 @@ export const articleBySlugQuery = `query PostBy($slug: ID!) {
       }
     }
     info {
-      readingTime
       wordsCount
     }
     modified
@@ -90,11 +89,6 @@ export const articlesQuery = `query Articles($after: String = "", $first: Int =
             }
           }
         }
-        author {
-          node {
-            name
-          }
-        }
         commentCount
         contentParts {
           beforeMore
@@ -113,7 +107,6 @@ export const articlesQuery = `query Articles($after: String = "", $first: Int =
           }
         }
         info {
-          readingTime
           wordsCount
         }
         modified
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index f130872..f0ba4e4 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -71,13 +71,12 @@ export const getArticleFromRawData = (data: RawArticle): Article => {
     id: databaseId,
     intro: contentParts.beforeMore,
     meta: {
-      author: getAuthorFromRawData(author.node, 'page'),
+      author: author && getAuthorFromRawData(author.node, 'page'),
       commentsCount: commentCount || 0,
       cover: featuredImage?.node
         ? getImageFromRawData(featuredImage.node)
         : undefined,
       dates: { publication: date, update: modified },
-      readingTime: info.readingTime,
       seo: {
         description: seo?.metaDesc || '',
         title: seo?.title || '',
diff --git a/src/services/graphql/thematics.query.ts b/src/services/graphql/thematics.query.ts
index 3b3ebd6..5a82133 100644
--- a/src/services/graphql/thematics.query.ts
+++ b/src/services/graphql/thematics.query.ts
@@ -15,13 +15,6 @@ export const thematicBySlugQuery = `query ThematicBy($slug: ID!) {
               }
             }
           }
-          author {
-            node {
-              gravatarUrl
-              name
-              url
-            }
-          }
           commentCount
           contentParts {
             beforeMore
@@ -40,7 +33,6 @@ export const thematicBySlugQuery = `query ThematicBy($slug: ID!) {
             }
           }
           info {
-            readingTime
             wordsCount
           }
           modified
@@ -67,7 +59,6 @@ export const thematicBySlugQuery = `query ThematicBy($slug: ID!) {
       }
     }
     info {
-      readingTime
       wordsCount
     }
     modified
diff --git a/src/services/graphql/thematics.ts b/src/services/graphql/thematics.ts
index e526db8..fd9d9f5 100644
--- a/src/services/graphql/thematics.ts
+++ b/src/services/graphql/thematics.ts
@@ -117,7 +117,6 @@ export const getThematicFromRawData = (data: RawThematic): Thematic => {
         ? getImageFromRawData(featuredImage.node)
         : undefined,
       dates: { publication: date, update: modified },
-      readingTime: info.readingTime,
       seo: {
         description: seo?.metaDesc || '',
         title: seo?.title || '',
diff --git a/src/services/graphql/topics.query.ts b/src/services/graphql/topics.query.ts
index 4574256..57b2569 100644
--- a/src/services/graphql/topics.query.ts
+++ b/src/services/graphql/topics.query.ts
@@ -16,13 +16,6 @@ export const topicBySlugQuery = `query TopicBy($slug: ID!) {
               }
             }
           }
-          author {
-            node {
-              gravatarUrl
-              name
-              url
-            }
-          }
           commentCount
           contentParts {
             beforeMore
@@ -41,7 +34,6 @@ export const topicBySlugQuery = `query TopicBy($slug: ID!) {
             }
           }
           info {
-            readingTime
             wordsCount
           }
           modified
@@ -68,7 +60,6 @@ export const topicBySlugQuery = `query TopicBy($slug: ID!) {
       }
     }
     info {
-      readingTime
       wordsCount
     }
     modified
diff --git a/src/services/graphql/topics.ts b/src/services/graphql/topics.ts
index 3c92442..277580f 100644
--- a/src/services/graphql/topics.ts
+++ b/src/services/graphql/topics.ts
@@ -119,7 +119,6 @@ export const getTopicFromRawData = (data: RawTopic): Topic => {
         : undefined,
       dates: { publication: date, update: modified },
       website: acfTopics.officialWebsite,
-      readingTime: info.readingTime,
       seo: {
         description: seo?.metaDesc || '',
         title: seo?.title || '',
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index feff5a5..cb5429b 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -44,7 +44,7 @@ export type Comment = {
   content: string;
   id: number;
   meta: CommentMeta;
-  parentId: number;
+  parentId?: number;
   replies: Comment[];
 };
 
@@ -80,7 +80,6 @@ export type Meta<T extends PageKind> = {
   cover?: Image;
   dates: Dates;
   license?: T extends 'project' ? string : never;
-  readingTime?: number;
   repos?: T extends 'project' ? Repos : never;
   seo: SEO;
   tagline?: T extends 'project' ? string : never;
@@ -88,7 +87,7 @@ export type Meta<T extends PageKind> = {
   thematics?: T extends 'article' | 'topic' ? PageLink[] : never;
   topics?: T extends 'article' | 'thematic' ? PageLink[] : never;
   website?: T extends 'topic' ? string : never;
-  wordsCount?: number;
+  wordsCount: number;
 };
 
 export type Page<T extends PageKind> = {
diff --git a/src/ts/types/mdx.ts b/src/ts/types/mdx.ts
index 6b72f21..16538c1 100644
--- a/src/ts/types/mdx.ts
+++ b/src/ts/types/mdx.ts
@@ -11,10 +11,7 @@ export type MDXPageMeta = Pick<Meta<'page'>, 'cover' | 'dates' | 'seo'> & {
   title: string;
 };
 
-export type MDXProjectMeta = Omit<
-  Meta<'project'>,
-  'readingTime' | 'wordsCount'
-> & {
+export type MDXProjectMeta = Exclude<Meta<'project'>, 'wordsCount'> & {
   intro: string;
   title: string;
 };
diff --git a/src/ts/types/raw-data.ts b/src/ts/types/raw-data.ts
index ba6d596..dc3db90 100644
--- a/src/ts/types/raw-data.ts
+++ b/src/ts/types/raw-data.ts
@@ -25,7 +25,6 @@ export type ContentParts = {
 };
 
 export type Info = {
-  readingTime: number;
   wordsCount: number;
 };
 
@@ -66,7 +65,7 @@ export type RawArticlePreview = Pick<
 >;
 
 export type RawPage = {
-  author: NodeResponse<RawAuthor<'page'>>;
+  author?: NodeResponse<RawAuthor<'page'>>;
   contentParts: ContentParts;
   databaseId: number;
   date: string;
diff --git a/src/utils/helpers/pages.ts b/src/utils/helpers/pages.ts
index 62337db..1a388e1 100644
--- a/src/utils/helpers/pages.ts
+++ b/src/utils/helpers/pages.ts
@@ -1,7 +1,10 @@
 import { type Post } from '@components/organisms/layout/posts-list';
 import { type LinksListItems } from '@components/organisms/widgets/links-list-widget';
-import { type Meta, type PageLink } from '@ts/types/app';
+import { type EdgesResponse } from '@services/graphql/api';
+import { getArticleFromRawData } from '@services/graphql/articles';
+import { type Article, type PageLink } from '@ts/types/app';
 import {
+  type RawArticle,
   type RawThematicPreview,
   type RawTopicPreview,
 } from '@ts/types/raw-data';
@@ -52,23 +55,35 @@ export const getLinksListItems = (
 };
 
 /**
- * Retrieve the formatted meta.
+ * Retrieve the posts list with the article URL.
  *
- * @param {Meta<'article'>} meta - The article meta.
- * @returns {Post['meta']} The formatted meta.
+ * @param {Article[]} posts - An array of articles.
+ * @returns {Post[]} An array of posts with full article URL.
  */
-export const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
-  const { commentsCount, dates, thematics, topics, wordsCount } = meta;
+export const getPostsWithUrl = (posts: Article[]): Post[] => {
+  return posts.map((post) => {
+    return {
+      ...post,
+      url: `/article/${post.slug}`,
+    };
+  });
+};
 
-  return {
-    commentsCount,
-    dates,
-    readingTime: { wordsCount: wordsCount || 0, onlyMinutes: true },
-    thematics: thematics?.map((thematic) => {
-      return { ...thematic, url: `/thematique/${thematic.slug}` };
-    }),
-    topics: topics?.map((topic) => {
-      return { ...topic, url: `/sujet/${topic.slug}` };
-    }),
-  };
+/**
+ * Retrieve the posts list from raw data.
+ *
+ * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
+ * @returns {Post[]} An array of posts.
+ */
+export const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
+  const articlesList: RawArticle[] = [];
+  rawData.forEach((articleData) =>
+    articleData.edges.forEach((edge) => {
+      articlesList.push(edge.node);
+    })
+  );
+
+  return getPostsWithUrl(
+    articlesList.map((article) => getArticleFromRawData(article))
+  );
 };
-- 
cgit v1.2.3


From 5ebd7c14f7303a0feb8ec1d902ecd0e287d929c3 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 23 May 2022 19:02:20 +0200
Subject: refactor(schema): use helpers function to avoid repeat between pages

---
 src/pages/article/[slug].tsx     |  94 +++++++---------
 src/pages/blog/index.tsx         |  49 +++------
 src/pages/blog/page/[number].tsx |  49 +++------
 src/pages/contact.tsx            |  60 ++++-------
 src/pages/cv.tsx                 |  61 ++++-------
 src/pages/index.tsx              |  26 ++---
 src/pages/mentions-legales.tsx   |  62 ++++-------
 src/pages/projets/[slug].tsx     |  60 ++++-------
 src/pages/projets/index.tsx      |  62 ++++-------
 src/pages/recherche/index.tsx    |  49 +++------
 src/pages/sujet/[slug].tsx       |  61 ++++-------
 src/pages/thematique/[slug].tsx  |  60 ++++-------
 src/utils/helpers/schema-org.ts  | 224 +++++++++++++++++++++++++++++++++++++++
 13 files changed, 468 insertions(+), 449 deletions(-)
 create mode 100644 src/utils/helpers/schema-org.ts

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 2878538..c72d48e 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -18,6 +18,12 @@ import {
   type NextPageWithLayout,
 } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import {
+  getBlogSchema,
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePrism, { type OptionalPrismPlugin } from '@utils/hooks/use-prism';
 import useReadingTime from '@utils/hooks/use-reading-time';
@@ -29,7 +35,6 @@ import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
 import { HTMLAttributes } from 'react';
 import { useIntl } from 'react-intl';
-import { Blog, BlogPosting, Graph, WebPage } from 'schema-dts';
 import useSWR from 'swr';
 
 type ArticlePageProps = {
@@ -108,64 +113,35 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    lastReviewed: dates.update,
-    name: seo.title,
-    description: seo.description,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const blogSchema: Blog = {
-    '@id': `${website.url}/#blog`,
-    '@type': 'Blog',
-    blogPost: { '@id': `${website.url}/#article` },
-    isPartOf: {
-      '@id': `${pageUrl}`,
-    },
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-  };
-
-  const blogPostSchema: BlogPosting = {
-    '@id': `${website.url}/#article`,
-    '@type': 'BlogPosting',
-    name: title,
+  const webpageSchema = getWebPageSchema({
     description: intro,
-    articleBody: content,
-    author: { '@id': `${website.url}/#branding` },
-    commentCount: commentsCount,
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    discussionUrl: `${pageUrl}/#comments`,
-    editor: { '@id': `${website.url}/#branding` },
-    headline: title,
-    image: cover?.src,
-    inLanguage: website.locales.default,
-    isPartOf: {
-      '@id': `${website.url}/blog`,
-    },
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-    thumbnailUrl: cover?.src,
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, blogSchema, blogPostSchema],
-  };
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+    updateDate: dates.update,
+  });
+  const blogSchema = getBlogSchema({
+    isSinglePage: true,
+    locale: website.locales.default,
+    slug: asPath,
+  });
+  const blogPostSchema = getSinglePageSchema({
+    commentsCount,
+    content,
+    cover: cover?.src,
+    dates,
+    description: intro,
+    id: 'article',
+    kind: 'post',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([
+    webpageSchema,
+    blogSchema,
+    blogPostSchema,
+  ]);
 
   const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers'];
   const { attributes, className } = usePrism({ plugins: prismPlugins });
@@ -202,6 +178,8 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     prismClassNameReplacer
   );
 
+  const pageUrl = `${website.url}${asPath}`;
+
   return (
     <>
       <Head>
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index dd04fad..cfd6f8c 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -23,6 +23,11 @@ import {
   getPageLinkFromRawData,
   getPostsList,
 } from '@utils/helpers/pages';
+import {
+  getBlogSchema,
+  getSchemaJson,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import usePagination from '@utils/hooks/use-pagination';
 import useSettings from '@utils/hooks/use-settings';
@@ -31,7 +36,6 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { useIntl } from 'react-intl';
-import { Blog, Graph, WebPage } from 'schema-dts';
 
 type BlogPageProps = {
   articles: EdgesResponse<RawArticle>;
@@ -80,37 +84,18 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
     },
     { websiteName: website.name }
   );
-  const pageUrl = `${website.url}${asPath}`;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: pageTitle,
+  const webpageSchema = getWebPageSchema({
     description: pageDescription,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const blogSchema: Blog = {
-    '@id': `${website.url}/#blog`,
-    '@type': 'Blog',
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    editor: { '@id': `${website.url}/#branding` },
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, blogSchema],
-  };
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const blogSchema = getBlogSchema({
+    isSinglePage: false,
+    locale: website.locales.default,
+    slug: asPath,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
 
   const {
     data,
@@ -149,7 +134,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="website" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={pageDescription} />
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index 8f50ddd..78b1db4 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -26,6 +26,11 @@ import {
   getPageLinkFromRawData,
   getPostsList,
 } from '@utils/helpers/pages';
+import {
+  getBlogSchema,
+  getSchemaJson,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useRedirection from '@utils/hooks/use-redirection';
 import useSettings from '@utils/hooks/use-settings';
@@ -35,7 +40,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
 import { useIntl } from 'react-intl';
-import { Blog, Graph, WebPage } from 'schema-dts';
 
 type BlogPageProps = {
   articles: EdgesResponse<RawArticle>;
@@ -95,37 +99,18 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
     },
     { websiteName: website.name }
   );
-  const pageUrl = `${website.url}${asPath}`;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: pageTitle,
+  const webpageSchema = getWebPageSchema({
     description: pageDescription,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const blogSchema: Blog = {
-    '@id': `${website.url}/#blog`,
-    '@type': 'Blog',
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    editor: { '@id': `${website.url}/#branding` },
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, blogSchema],
-  };
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const blogSchema = getBlogSchema({
+    isSinglePage: false,
+    locale: website.locales.default,
+    slug: asPath,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
 
   const thematicsListTitle = intl.formatMessage({
     defaultMessage: 'Thematics',
@@ -144,7 +129,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="website" />
         <meta property="og:title" content={pageTitleWithPageNumber} />
         <meta property="og:description" content={pageDescription} />
diff --git a/src/pages/contact.tsx b/src/pages/contact.tsx
index c0d6c79..2392fe2 100644
--- a/src/pages/contact.tsx
+++ b/src/pages/contact.tsx
@@ -6,10 +6,15 @@ import SocialMedia from '@components/organisms/widgets/social-media';
 import { getLayout } from '@components/templates/layout/layout';
 import PageLayout from '@components/templates/page/page-layout';
 import { meta } from '@content/pages/contact.mdx';
-import styles from '@styles/pages/contact.module.scss';
 import { sendMail } from '@services/graphql/contact';
+import styles from '@styles/pages/contact.module.scss';
 import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticProps } from 'next';
@@ -18,7 +23,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { useState } from 'react';
 import { useIntl } from 'react-intl';
-import { ContactPage as ContactPageSchema, Graph, WebPage } from 'schema-dts';
 
 const ContactPage: NextPageWithLayout = () => {
   const { dates, intro, seo, title } = meta;
@@ -36,43 +40,23 @@ const ContactPage: NextPageWithLayout = () => {
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const contactSchema: ContactPageSchema = {
-    '@id': `${website.url}/#contact`,
-    '@type': 'ContactPage',
-    name: title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const contactSchema = getSinglePageSchema({
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, contactSchema],
-  };
+    id: 'contact',
+    kind: 'contact',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, contactSchema]);
 
   const widgets = [
     <SocialMedia
@@ -134,7 +118,7 @@ const ContactPage: NextPageWithLayout = () => {
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={intro} />
diff --git a/src/pages/cv.tsx b/src/pages/cv.tsx
index 3f035d8..4686505 100644
--- a/src/pages/cv.tsx
+++ b/src/pages/cv.tsx
@@ -11,6 +11,11 @@ import CVContent, { data, meta } from '@content/pages/cv.mdx';
 import styles from '@styles/pages/cv.module.scss';
 import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
@@ -20,7 +25,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import React, { ReactNode } from 'react';
 import { useIntl } from 'react-intl';
-import { AboutPage, Graph, WebPage } from 'schema-dts';
 
 /**
  * CV page.
@@ -106,45 +110,24 @@ const CVPage: NextPageWithLayout = () => {
   ];
 
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const cvSchema: AboutPage = {
-    '@id': `${website.url}/#cv`,
-    '@type': 'AboutPage',
-    name: seo.title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const cvSchema = getSinglePageSchema({
+    cover: image.src,
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    image: image.src,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    thumbnailUrl: image.src,
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, cvSchema],
-  };
+    id: 'cv',
+    kind: 'about',
+    locale: website.locales.default,
+    slug: asPath,
+    title: title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, cvSchema]);
 
   const components: NestedMDXComponents = {
     a: (props) => <Link external={true} {...props} />,
@@ -171,7 +154,7 @@ const CVPage: NextPageWithLayout = () => {
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={intro} />
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index a831ea3..6e9c4c6 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -19,6 +19,7 @@ import { getArticlesCard } from '@services/graphql/articles';
 import styles from '@styles/pages/home.module.scss';
 import { type ArticleCard, type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import { getSchemaJson, getWebPageSchema } from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
@@ -27,7 +28,6 @@ import Head from 'next/head';
 import Script from 'next/script';
 import { ReactElement } from 'react';
 import { useIntl } from 'react-intl';
-import { Graph, WebPage } from 'schema-dts';
 
 type HomeProps = {
   recentPosts: ArticleCard[];
@@ -306,25 +306,13 @@ const HomePage: NextPageWithLayout<HomeProps> = ({ recentPosts }) => {
     },
     { websiteName: website.name }
   );
-
-  const webpageSchema: WebPage = {
-    '@id': `${website.url}/#home`,
-    '@type': 'WebPage',
-    name: pageTitle,
+  const webpageSchema = getWebPageSchema({
     description: pageDescription,
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    editor: { '@id': `${website.url}/#branding` },
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema],
-  };
+    locale: website.locales.default,
+    slug: '',
+    title: pageTitle,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema]);
 
   return (
     <>
diff --git a/src/pages/mentions-legales.tsx b/src/pages/mentions-legales.tsx
index c8d1772..a58a850 100644
--- a/src/pages/mentions-legales.tsx
+++ b/src/pages/mentions-legales.tsx
@@ -7,6 +7,11 @@ import PageLayout, {
 import LegalNoticeContent, { meta } from '@content/pages/legal-notice.mdx';
 import { type NextPageWithLayout } from '@ts/types/app';
 import { loadTranslation } from '@utils/helpers/i18n';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
@@ -14,7 +19,6 @@ import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
-import { Article, Graph, WebPage } from 'schema-dts';
 
 /**
  * Legal Notice page.
@@ -44,47 +48,23 @@ const LegalNoticePage: NextPageWithLayout = () => {
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const articleSchema: Article = {
-    '@id': `${website.url}/#legal-notice`,
-    '@type': 'Article',
-    name: title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const articleSchema = getSinglePageSchema({
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: title,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
+    id: 'legal-notice',
+    kind: 'page',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
 
   return (
     <PageLayout
@@ -98,7 +78,7 @@ const LegalNoticePage: NextPageWithLayout = () => {
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={`${seo.title} - ${website.name}`} />
         <meta property="og:description" content={intro} />
diff --git a/src/pages/projets/[slug].tsx b/src/pages/projets/[slug].tsx
index 27c715d..247f350 100644
--- a/src/pages/projets/[slug].tsx
+++ b/src/pages/projets/[slug].tsx
@@ -22,6 +22,11 @@ import {
 } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import { getProjectData, getProjectFilenames } from '@utils/helpers/projects';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import { capitalize } from '@utils/helpers/strings';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useGithubApi, { type RepoData } from '@utils/hooks/use-github-api';
@@ -33,7 +38,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ComponentType } from 'react';
 import { useIntl } from 'react-intl';
-import { Article, Graph, WebPage } from 'schema-dts';
 
 type ProjectPageProps = {
   project: ProjectPreview;
@@ -66,8 +70,6 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
   const { website } = useSettings();
   const { asPath } = useRouter();
   const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     publication: { date: dates.publication },
@@ -137,44 +139,24 @@ const ProjectPage: NextPageWithLayout<ProjectPageProps> = ({ project }) => {
     technologies,
   };
 
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const articleSchema: Article = {
-    '@id': `${website.url}/project`,
-    '@type': 'Article',
-    name: title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const articleSchema = getSinglePageSchema({
+    cover: `/projects/${id}.jpg`,
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: title,
-    thumbnailUrl: `/projects/${id}.jpg`,
-    image: `/projects/${id}.jpg`,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
+    id: 'project',
+    kind: 'page',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
 
   return (
     <>
diff --git a/src/pages/projets/index.tsx b/src/pages/projets/index.tsx
index 9ca289a..dbca019 100644
--- a/src/pages/projets/index.tsx
+++ b/src/pages/projets/index.tsx
@@ -9,6 +9,11 @@ import styles from '@styles/pages/projects.module.scss';
 import { type NextPageWithLayout, type ProjectCard } from '@ts/types/app';
 import { loadTranslation, type Messages } from '@utils/helpers/i18n';
 import { getProjectsCard } from '@utils/helpers/projects';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { NestedMDXComponents } from 'mdx/types';
@@ -16,7 +21,6 @@ import { GetStaticProps } from 'next';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
-import { Article, Graph, WebPage } from 'schema-dts';
 
 type ProjectsPageProps = {
   projects: ProjectCard[];
@@ -54,54 +58,30 @@ const ProjectsPage: NextPageWithLayout<ProjectsPageProps> = ({ projects }) => {
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${pageUrl}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const articleSchema: Article = {
-    '@id': `${website.url}/#projects`,
-    '@type': 'Article',
-    name: meta.title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const articleSchema = getSinglePageSchema({
+    dates,
     description: seo.description,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: meta.title,
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
+    id: 'projects',
+    kind: 'page',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
 
   return (
     <>
       <Head>
         <title>{`${seo.title} - ${website.name}`}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={`${seo.title} - ${website.name}`} />
         <meta property="og:description" content={seo.description} />
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index c69d931..ab619fb 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -22,6 +22,11 @@ import {
   getPageLinkFromRawData,
   getPostsList,
 } from '@utils/helpers/pages';
+import {
+  getBlogSchema,
+  getSchemaJson,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useDataFromAPI from '@utils/hooks/use-data-from-api';
 import usePagination from '@utils/hooks/use-pagination';
@@ -31,7 +36,6 @@ import Head from 'next/head';
 import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { useIntl } from 'react-intl';
-import { Blog, Graph, WebPage } from 'schema-dts';
 
 type SearchPageProps = {
   thematicsList: RawThematicPreview[];
@@ -87,37 +91,18 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
         },
         { websiteName: website.name }
       );
-  const pageUrl = `${website.url}${asPath}`;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: pageTitle,
+  const webpageSchema = getWebPageSchema({
     description: pageDescription,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-    isPartOf: {
-      '@id': `${website.url}`,
-    },
-  };
-
-  const blogSchema: Blog = {
-    '@id': `${website.url}/#blog`,
-    '@type': 'Blog',
-    author: { '@id': `${website.url}/#branding` },
-    creator: { '@id': `${website.url}/#branding` },
-    editor: { '@id': `${website.url}/#branding` },
-    inLanguage: website.locales.default,
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, blogSchema],
-  };
+    locale: website.locales.default,
+    slug: asPath,
+    title: pageTitle,
+  });
+  const blogSchema = getBlogSchema({
+    isSinglePage: false,
+    locale: website.locales.default,
+    slug: asPath,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]);
 
   const {
     data,
@@ -161,7 +146,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
       <Head>
         <title>{pageTitle}</title>
         <meta name="description" content={pageDescription} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="website" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={pageDescription} />
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 95843cd..838f009 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -24,6 +24,11 @@ import {
   getPageLinkFromRawData,
   getPostsWithUrl,
 } from '@utils/helpers/pages';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps } from 'next';
@@ -32,7 +37,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
 import { useIntl } from 'react-intl';
-import { Article as ArticleSchema, Graph, WebPage } from 'schema-dts';
 
 export type TopicPageProps = {
   currentTopic: Topic;
@@ -68,45 +72,24 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-  };
-
-  const articleSchema: ArticleSchema = {
-    '@id': `${website.url}/#topic`,
-    '@type': 'Article',
-    name: title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const articleSchema = getSinglePageSchema({
+    cover: cover?.src,
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: title,
-    inLanguage: website.locales.default,
-    isPartOf: { '@id': `${website.url}/blog` },
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-    subjectOf: { '@id': `${website.url}/blog` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
+    id: 'topic',
+    kind: 'page',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
 
   const topicsListTitle = intl.formatMessage({
     defaultMessage: 'Other topics',
@@ -134,7 +117,7 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
       <Head>
         <title>{seo.title}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={intro} />
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index a91483e..84ab134 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -22,6 +22,11 @@ import {
   getPageLinkFromRawData,
   getPostsWithUrl,
 } from '@utils/helpers/pages';
+import {
+  getSchemaJson,
+  getSinglePageSchema,
+  getWebPageSchema,
+} from '@utils/helpers/schema-org';
 import useBreadcrumb from '@utils/hooks/use-breadcrumb';
 import useSettings from '@utils/hooks/use-settings';
 import { GetStaticPaths, GetStaticProps } from 'next';
@@ -30,7 +35,6 @@ import { useRouter } from 'next/router';
 import Script from 'next/script';
 import { ParsedUrlQuery } from 'querystring';
 import { useIntl } from 'react-intl';
-import { Article as ArticleSchema, Graph, WebPage } from 'schema-dts';
 
 export type ThematicPageProps = {
   currentThematic: Thematic;
@@ -58,45 +62,23 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
 
   const { website } = useSettings();
   const { asPath } = useRouter();
-  const pageUrl = `${website.url}${asPath}`;
-  const pagePublicationDate = new Date(dates.publication);
-  const pageUpdateDate = dates.update ? new Date(dates.update) : undefined;
-
-  const webpageSchema: WebPage = {
-    '@id': `${pageUrl}`,
-    '@type': 'WebPage',
-    breadcrumb: { '@id': `${website.url}/#breadcrumb` },
-    name: seo.title,
+  const webpageSchema = getWebPageSchema({
     description: seo.description,
-    inLanguage: website.locales.default,
-    reviewedBy: { '@id': `${website.url}/#branding` },
-    url: `${website.url}`,
-  };
-
-  const articleSchema: ArticleSchema = {
-    '@id': `${website.url}/#thematic`,
-    '@type': 'Article',
-    name: title,
+    locale: website.locales.default,
+    slug: asPath,
+    title: seo.title,
+    updateDate: dates.update,
+  });
+  const articleSchema = getSinglePageSchema({
+    dates,
     description: intro,
-    author: { '@id': `${website.url}/#branding` },
-    copyrightYear: pagePublicationDate.getFullYear(),
-    creator: { '@id': `${website.url}/#branding` },
-    dateCreated: pagePublicationDate.toISOString(),
-    dateModified: pageUpdateDate && pageUpdateDate.toISOString(),
-    datePublished: pagePublicationDate.toISOString(),
-    editor: { '@id': `${website.url}/#branding` },
-    headline: title,
-    inLanguage: website.locales.default,
-    isPartOf: { '@id': `${website.url}/blog` },
-    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
-    mainEntityOfPage: { '@id': `${pageUrl}` },
-    subjectOf: { '@id': `${website.url}/blog` },
-  };
-
-  const schemaJsonLd: Graph = {
-    '@context': 'https://schema.org',
-    '@graph': [webpageSchema, articleSchema],
-  };
+    id: 'thematic',
+    kind: 'page',
+    locale: website.locales.default,
+    slug: asPath,
+    title,
+  });
+  const schemaJsonLd = getSchemaJson([webpageSchema, articleSchema]);
 
   const thematicsListTitle = intl.formatMessage({
     defaultMessage: 'Other thematics',
@@ -115,7 +97,7 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
       <Head>
         <title>{seo.title}</title>
         <meta name="description" content={seo.description} />
-        <meta property="og:url" content={`${pageUrl}`} />
+        <meta property="og:url" content={`${website.url}${asPath}`} />
         <meta property="og:type" content="article" />
         <meta property="og:title" content={title} />
         <meta property="og:description" content={intro} />
diff --git a/src/utils/helpers/schema-org.ts b/src/utils/helpers/schema-org.ts
new file mode 100644
index 0000000..cdace00
--- /dev/null
+++ b/src/utils/helpers/schema-org.ts
@@ -0,0 +1,224 @@
+import { Dates } from '@ts/types/app';
+import { settings } from '@utils/config';
+import {
+  AboutPage,
+  Article,
+  Blog,
+  BlogPosting,
+  ContactPage,
+  Graph,
+  WebPage,
+} from 'schema-dts';
+
+export type GetBlogSchemaProps = {
+  /**
+   * True if the page is part of the blog.
+   */
+  isSinglePage: boolean;
+  /**
+   * The page locale.
+   */
+  locale: string;
+  /**
+   * The page slug with a leading slash.
+   */
+  slug: string;
+};
+
+/**
+ * Retrieve the JSON for Blog schema.
+ *
+ * @param props - The page data.
+ * @returns {Blog} The JSON for Blog schema.
+ */
+export const getBlogSchema = ({
+  isSinglePage,
+  locale,
+  slug,
+}: GetBlogSchemaProps): Blog => {
+  return {
+    '@id': `${settings.url}/#blog`,
+    '@type': 'Blog',
+    author: { '@id': `${settings.url}/#branding` },
+    creator: { '@id': `${settings.url}/#branding` },
+    editor: { '@id': `${settings.url}/#branding` },
+    blogPost: isSinglePage ? { '@id': `${settings.url}/#article` } : undefined,
+    inLanguage: locale,
+    isPartOf: isSinglePage
+      ? {
+          '@id': `${settings.url}${slug}`,
+        }
+      : undefined,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    mainEntityOfPage: isSinglePage
+      ? undefined
+      : { '@id': `${settings.url}${slug}` },
+  };
+};
+
+export type SinglePageSchemaReturn = {
+  about: AboutPage;
+  contact: ContactPage;
+  page: Article;
+  post: BlogPosting;
+};
+
+export type SinglePageSchemaKind = keyof SinglePageSchemaReturn;
+
+export type GetSinglePageSchemaProps<T extends SinglePageSchemaKind> = {
+  /**
+   * The number of comments.
+   */
+  commentsCount?: number;
+  /**
+   * The page content.
+   */
+  content?: string;
+  /**
+   * The url of the cover.
+   */
+  cover?: string;
+  /**
+   * The page dates.
+   */
+  dates: Dates;
+  /**
+   * The page description.
+   */
+  description: string;
+  /**
+   * The page id.
+   */
+  id: string;
+  /**
+   * The page kind.
+   */
+  kind: T;
+  /**
+   * The page locale.
+   */
+  locale: string;
+  /**
+   * The page slug with a leading slash.
+   */
+  slug: string;
+  /**
+   * The page title.
+   */
+  title: string;
+};
+
+/**
+ * Retrieve the JSON schema depending on the page kind.
+ *
+ * @param props - The page data.
+ * @returns {SinglePageSchemaReturn[T]} - Either AboutPage, ContactPage, Article or BlogPosting schema.
+ */
+export const getSinglePageSchema = <T extends SinglePageSchemaKind>({
+  commentsCount,
+  content,
+  cover,
+  dates,
+  description,
+  id,
+  kind,
+  locale,
+  title,
+  slug,
+}: GetSinglePageSchemaProps<T>): SinglePageSchemaReturn[T] => {
+  const publicationDate = new Date(dates.publication);
+  const updateDate = dates.update ? new Date(dates.update) : undefined;
+  const singlePageSchemaType = {
+    about: 'AboutPage',
+    contact: 'ContactPage',
+    page: 'Article',
+    post: 'BlogPosting',
+  };
+
+  return {
+    '@id': `${settings.url}/#${id}`,
+    '@type': singlePageSchemaType[kind],
+    name: title,
+    description,
+    articleBody: content,
+    author: { '@id': `${settings.url}/#branding` },
+    commentCount: commentsCount,
+    copyrightYear: publicationDate.getFullYear(),
+    creator: { '@id': `${settings.url}/#branding` },
+    dateCreated: publicationDate.toISOString(),
+    dateModified: updateDate && updateDate.toISOString(),
+    datePublished: publicationDate.toISOString(),
+    editor: { '@id': `${settings.url}/#branding` },
+    headline: title,
+    image: cover,
+    inLanguage: locale,
+    license: 'https://creativecommons.org/licenses/by-sa/4.0/deed.fr',
+    thumbnailUrl: cover,
+    isPartOf:
+      kind === 'post'
+        ? {
+            '@id': `${settings.url}/blog`,
+          }
+        : undefined,
+    mainEntityOfPage: { '@id': `${settings.url}${slug}` },
+  } as SinglePageSchemaReturn[T];
+};
+
+export type GetWebPageSchemaProps = {
+  /**
+   * The page description.
+   */
+  description: string;
+  /**
+   * The page locale.
+   */
+  locale: string;
+  /**
+   * The page slug.
+   */
+  slug: string;
+  /**
+   * The page title.
+   */
+  title: string;
+  /**
+   * The page last update.
+   */
+  updateDate?: string;
+};
+
+/**
+ * Retrieve the JSON for WebPage schema.
+ *
+ * @param props - The page data.
+ * @returns {WebPage} The JSON for WebPage schema.
+ */
+export const getWebPageSchema = ({
+  description,
+  locale,
+  slug,
+  title,
+  updateDate,
+}: GetWebPageSchemaProps): WebPage => {
+  return {
+    '@id': `${settings.url}${slug}`,
+    '@type': 'WebPage',
+    breadcrumb: { '@id': `${settings.url}/#breadcrumb` },
+    lastReviewed: updateDate,
+    name: title,
+    description: description,
+    inLanguage: locale,
+    reviewedBy: { '@id': `${settings.url}/#branding` },
+    url: `${settings.url}${slug}`,
+    isPartOf: {
+      '@id': `${settings.url}`,
+    },
+  };
+};
+
+export const getSchemaJson = (graphs: Graph['@graph']): Graph => {
+  return {
+    '@context': 'https://schema.org',
+    '@graph': graphs,
+  };
+};
-- 
cgit v1.2.3


From c51b5d9a5f217f8aa59c4bdcb04eb3c87f5129b3 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Mon, 23 May 2022 19:32:42 +0200
Subject: fix: ensure all topics/thematics have the base url

---
 src/components/organisms/layout/posts-list.fixture.tsx |  8 ++++----
 src/components/organisms/layout/summary.fixture.tsx    |  4 ++--
 src/components/organisms/layout/summary.tsx            |  4 ++--
 src/pages/404.tsx                                      |  8 ++++----
 src/pages/article/[slug].tsx                           |  8 ++------
 src/pages/blog/index.tsx                               |  8 ++++----
 src/pages/blog/page/[number].tsx                       |  8 ++++----
 src/pages/recherche/index.tsx                          |  8 ++++----
 src/pages/sujet/[slug].tsx                             |  8 ++++----
 src/pages/thematique/[slug].tsx                        |  9 +++++----
 src/services/graphql/articles.ts                       |  4 ++--
 src/services/graphql/thematics.ts                      |  2 +-
 src/services/graphql/topics.ts                         |  2 +-
 src/ts/types/app.ts                                    |  2 +-
 src/utils/helpers/pages.ts                             | 16 ++++++----------
 15 files changed, 46 insertions(+), 53 deletions(-)

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/components/organisms/layout/posts-list.fixture.tsx b/src/components/organisms/layout/posts-list.fixture.tsx
index b0afdf8..e3f2378 100644
--- a/src/components/organisms/layout/posts-list.fixture.tsx
+++ b/src/components/organisms/layout/posts-list.fixture.tsx
@@ -25,8 +25,8 @@ export const posts: Post[] = [
       dates: { publication: '2022-02-26' },
       wordsCount: introPost1.split(' ').length,
       thematics: [
-        { id: 1, name: 'Cat 1', slug: '#' },
-        { id: 2, name: 'Cat 2', slug: '#' },
+        { id: 1, name: 'Cat 1', url: '#' },
+        { id: 2, name: 'Cat 2', url: '#' },
       ],
       commentsCount: 1,
     },
@@ -39,7 +39,7 @@ export const posts: Post[] = [
     meta: {
       dates: { publication: '2022-02-20' },
       wordsCount: introPost2.split(' ').length,
-      thematics: [{ id: 2, name: 'Cat 2', slug: '#' }],
+      thematics: [{ id: 2, name: 'Cat 2', url: '#' }],
       commentsCount: 0,
     },
     title: 'Debitis laudantium laudantium',
@@ -52,7 +52,7 @@ export const posts: Post[] = [
       cover,
       dates: { publication: '2021-12-20' },
       wordsCount: introPost3.split(' ').length,
-      thematics: [{ id: 1, name: 'Cat 1', slug: '#' }],
+      thematics: [{ id: 1, name: 'Cat 1', url: '#' }],
       commentsCount: 3,
     },
     title: 'Quaerat ut corporis',
diff --git a/src/components/organisms/layout/summary.fixture.tsx b/src/components/organisms/layout/summary.fixture.tsx
index fbc46a5..bb3ebcb 100644
--- a/src/components/organisms/layout/summary.fixture.tsx
+++ b/src/components/organisms/layout/summary.fixture.tsx
@@ -14,8 +14,8 @@ export const meta: SummaryMeta = {
   dates: { publication: '2022-04-11' },
   wordsCount: intro.split(' ').length,
   thematics: [
-    { id: 1, name: 'Cat 1', slug: '#' },
-    { id: 2, name: 'Cat 2', slug: '#' },
+    { id: 1, name: 'Cat 1', url: '#' },
+    { id: 2, name: 'Cat 2', url: '#' },
   ],
   commentsCount: 1,
 };
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index ca51cd6..8807878 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -83,12 +83,12 @@ const Summary: FC<SummaryProps> = ({
           : undefined,
       readingTime,
       thematics: thematics?.map((thematic) => (
-        <Link key={thematic.id} href={thematic.slug}>
+        <Link key={thematic.id} href={thematic.url}>
           {thematic.name}
         </Link>
       )),
       topics: topics?.map((topic) => (
-        <Link key={topic.id} href={topic.slug}>
+        <Link key={topic.id} href={topic.url}>
           {topic.name}
         </Link>
       )),
diff --git a/src/pages/404.tsx b/src/pages/404.tsx
index 4ab7784..fd7187a 100644
--- a/src/pages/404.tsx
+++ b/src/pages/404.tsx
@@ -98,8 +98,9 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({
           <LinksListWidget
             key="thematics-list"
             items={getLinksListItems(
-              thematicsList.map(getPageLinkFromRawData),
-              'thematic'
+              thematicsList.map((thematic) =>
+                getPageLinkFromRawData(thematic, 'thematic')
+              )
             )}
             title={thematicsListTitle}
             level={2}
@@ -107,8 +108,7 @@ const Error404Page: NextPageWithLayout<Error404PageProps> = ({
           <LinksListWidget
             key="topics-list"
             items={getLinksListItems(
-              topicsList.map(getPageLinkFromRawData),
-              'topic'
+              topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
             )}
             title={topicsListTitle}
             level={2}
diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index c72d48e..5e75ae7 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -81,7 +81,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     thematics:
       thematics &&
       thematics.map((thematic) => (
-        <Link key={thematic.id} href={`/thematique/${thematic.slug}`}>
+        <Link key={thematic.id} href={thematic.url}>
           {thematic.name}
         </Link>
       )),
@@ -99,11 +99,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
       label: footerMetaLabel,
       value: topics.map((topic) => {
         return (
-          <ButtonLink
-            key={topic.id}
-            target={`/sujet/${topic.slug}`}
-            className={styles.btn}
-          >
+          <ButtonLink key={topic.id} target={topic.url} className={styles.btn}>
             {topic.logo && <ResponsiveImage {...topic.logo} />} {topic.name}
           </ButtonLink>
         );
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index cfd6f8c..3956308 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -153,8 +153,9 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
           <LinksListWidget
             key="thematics-list"
             items={getLinksListItems(
-              thematicsList.map(getPageLinkFromRawData),
-              'thematic'
+              thematicsList.map((thematic) =>
+                getPageLinkFromRawData(thematic, 'thematic')
+              )
             )}
             title={thematicsListTitle}
             level={2}
@@ -162,8 +163,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
           <LinksListWidget
             key="topics-list"
             items={getLinksListItems(
-              topicsList.map(getPageLinkFromRawData),
-              'topic'
+              topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
             )}
             title={topicsListTitle}
             level={2}
diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx
index 78b1db4..b5efd27 100644
--- a/src/pages/blog/page/[number].tsx
+++ b/src/pages/blog/page/[number].tsx
@@ -148,8 +148,9 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
           <LinksListWidget
             key="thematics-list"
             items={getLinksListItems(
-              thematicsList.map(getPageLinkFromRawData),
-              'thematic'
+              thematicsList.map((thematic) =>
+                getPageLinkFromRawData(thematic, 'thematic')
+              )
             )}
             title={thematicsListTitle}
             level={2}
@@ -157,8 +158,7 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({
           <LinksListWidget
             key="topics-list"
             items={getLinksListItems(
-              topicsList.map(getPageLinkFromRawData),
-              'topic'
+              topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
             )}
             title={topicsListTitle}
             level={2}
diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx
index ab619fb..ffdbf44 100644
--- a/src/pages/recherche/index.tsx
+++ b/src/pages/recherche/index.tsx
@@ -165,8 +165,9 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
           <LinksListWidget
             key="thematics-list"
             items={getLinksListItems(
-              thematicsList.map(getPageLinkFromRawData),
-              'thematic'
+              thematicsList.map((thematic) =>
+                getPageLinkFromRawData(thematic, 'thematic')
+              )
             )}
             title={thematicsListTitle}
             level={2}
@@ -174,8 +175,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({
           <LinksListWidget
             key="topics-list"
             items={getLinksListItems(
-              topicsList.map(getPageLinkFromRawData),
-              'topic'
+              topicsList.map((topic) => getPageLinkFromRawData(topic, 'topic'))
             )}
             title={topicsListTitle}
             level={2}
diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx
index 838f009..5c86720 100644
--- a/src/pages/sujet/[slug].tsx
+++ b/src/pages/sujet/[slug].tsx
@@ -138,13 +138,13 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({
             ? [
                 <LinksListWidget
                   key="related-thematics"
-                  items={getLinksListItems(thematics, 'thematic')}
+                  items={getLinksListItems(thematics)}
                   title={thematicsListTitle}
                   level={2}
                 />,
                 <LinksListWidget
                   key="topics"
-                  items={getLinksListItems(topics, 'topic')}
+                  items={getLinksListItems(topics)}
                   title={topicsListTitle}
                   level={2}
                 />,
@@ -198,10 +198,10 @@ export const getStaticProps: GetStaticProps<TopicPageProps> = async ({
     first: totalTopics,
   });
   const allTopics = allTopicsEdges.edges.map((edge) =>
-    getPageLinkFromRawData(edge.node)
+    getPageLinkFromRawData(edge.node, 'topic')
   );
   const topicsLinks = allTopics.filter(
-    (topic) => topic.slug !== (params!.slug as TopicParams['slug'])
+    (topic) => topic.url !== `/sujet/${params!.slug as TopicParams['slug']}`
   );
   const translation = await loadTranslation(locale);
 
diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx
index 84ab134..0519731 100644
--- a/src/pages/thematique/[slug].tsx
+++ b/src/pages/thematique/[slug].tsx
@@ -118,13 +118,13 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({
             ? [
                 <LinksListWidget
                   key="thematics"
-                  items={getLinksListItems(thematics, 'thematic')}
+                  items={getLinksListItems(thematics)}
                   title={thematicsListTitle}
                   level={2}
                 />,
                 <LinksListWidget
                   key="related-topics"
-                  items={getLinksListItems(topics, 'topic')}
+                  items={getLinksListItems(topics)}
                   title={topicsListTitle}
                   level={2}
                 />,
@@ -178,10 +178,11 @@ export const getStaticProps: GetStaticProps<ThematicPageProps> = async ({
     first: totalThematics,
   });
   const allThematics = allThematicsEdges.edges.map((edge) =>
-    getPageLinkFromRawData(edge.node)
+    getPageLinkFromRawData(edge.node, 'thematic')
   );
   const allThematicsLinks = allThematics.filter(
-    (thematic) => thematic.slug !== (params!.slug as ThematicParams['slug'])
+    (thematic) =>
+      thematic.url !== `/thematique/${params!.slug as ThematicParams['slug']}`
   );
   const translation = await loadTranslation(locale);
 
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index f0ba4e4..27406ac 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -82,10 +82,10 @@ export const getArticleFromRawData = (data: RawArticle): Article => {
         title: seo?.title || '',
       },
       thematics: acfPosts.postsInThematic?.map((thematic) =>
-        getPageLinkFromRawData(thematic)
+        getPageLinkFromRawData(thematic, 'thematic')
       ),
       topics: acfPosts.postsInTopic?.map((topic) =>
-        getPageLinkFromRawData(topic)
+        getPageLinkFromRawData(topic, 'topic')
       ),
       wordsCount: info.wordsCount,
     },
diff --git a/src/services/graphql/thematics.ts b/src/services/graphql/thematics.ts
index fd9d9f5..4dc69e7 100644
--- a/src/services/graphql/thematics.ts
+++ b/src/services/graphql/thematics.ts
@@ -79,7 +79,7 @@ export const getThematicFromRawData = (data: RawThematic): Thematic => {
     posts.forEach((post) => {
       if (post.acfPosts.postsInTopic) {
         post.acfPosts.postsInTopic.forEach((topic) =>
-          topics.push(getPageLinkFromRawData(topic))
+          topics.push(getPageLinkFromRawData(topic, 'topic'))
         );
       }
     });
diff --git a/src/services/graphql/topics.ts b/src/services/graphql/topics.ts
index 277580f..0b1971b 100644
--- a/src/services/graphql/topics.ts
+++ b/src/services/graphql/topics.ts
@@ -80,7 +80,7 @@ export const getTopicFromRawData = (data: RawTopic): Topic => {
     posts.forEach((post) => {
       if (post.acfPosts.postsInThematic) {
         post.acfPosts.postsInThematic.forEach((thematic) =>
-          thematics.push(getPageLinkFromRawData(thematic))
+          thematics.push(getPageLinkFromRawData(thematic, 'thematic'))
         );
       }
     });
diff --git a/src/ts/types/app.ts b/src/ts/types/app.ts
index cb5429b..7bf1541 100644
--- a/src/ts/types/app.ts
+++ b/src/ts/types/app.ts
@@ -103,7 +103,7 @@ export type PageLink = {
   id: number;
   logo?: Image;
   name: string;
-  slug: string;
+  url: string;
 };
 
 export type Article = Page<'article'>;
diff --git a/src/utils/helpers/pages.ts b/src/utils/helpers/pages.ts
index 1a388e1..773d454 100644
--- a/src/utils/helpers/pages.ts
+++ b/src/utils/helpers/pages.ts
@@ -21,15 +21,17 @@ import { getImageFromRawData } from './images';
  * @returns {PageLink} The link data (id, slug and title).
  */
 export const getPageLinkFromRawData = (
-  data: RawThematicPreview | RawTopicPreview
+  data: RawThematicPreview | RawTopicPreview,
+  kind: 'thematic' | 'topic'
 ): PageLink => {
   const { databaseId, featuredImage, slug, title } = data;
+  const baseUrl = kind === 'thematic' ? '/thematique/' : '/sujet/';
 
   return {
     id: databaseId,
     logo: featuredImage ? getImageFromRawData(featuredImage?.node) : undefined,
     name: title,
-    slug,
+    url: `${baseUrl}${slug}`,
   };
 };
 
@@ -37,19 +39,13 @@ export const getPageLinkFromRawData = (
  * Convert page link data to an array of links items.
  *
  * @param {PageLink[]} links - An array of page links.
- * @param {'thematic'|'topic'} kind - The page links kind.
  * @returns {LinksListItem[]} An array of links items.
  */
-export const getLinksListItems = (
-  links: PageLink[],
-  kind: 'thematic' | 'topic'
-): LinksListItems[] => {
-  const baseUrl = kind === 'thematic' ? '/thematique/' : '/sujet/';
-
+export const getLinksListItems = (links: PageLink[]): LinksListItems[] => {
   return links.map((link) => {
     return {
       name: link.name,
-      url: `${baseUrl}${link.slug}`,
+      url: link.url,
     };
   });
 };
-- 
cgit v1.2.3


From c52843d33b5e144f654269043828310def851088 Mon Sep 17 00:00:00 2001
From: Armand Philippot <git@armandphilippot.com>
Date: Tue, 24 May 2022 18:21:50 +0200
Subject: chore: handle article fallback and revalidation

By using useSWR I can fetch the changes from the backend.
By using fallback, I can access new articles without rebuilding the
entire app.
---
 src/pages/article/[slug].tsx | 51 ++++++++++++++++++++++----------------------
 1 file changed, 26 insertions(+), 25 deletions(-)

(limited to 'src/pages/article/[slug].tsx')

diff --git a/src/pages/article/[slug].tsx b/src/pages/article/[slug].tsx
index 5e75ae7..ea679ab 100644
--- a/src/pages/article/[slug].tsx
+++ b/src/pages/article/[slug].tsx
@@ -1,5 +1,6 @@
 import ButtonLink from '@components/atoms/buttons/button-link';
 import Link from '@components/atoms/links/link';
+import Spinner from '@components/atoms/loaders/spinner';
 import ResponsiveImage from '@components/molecules/images/responsive-image';
 import Sharing from '@components/organisms/widgets/sharing';
 import { getLayout } from '@components/templates/layout/layout';
@@ -40,6 +41,7 @@ import useSWR from 'swr';
 type ArticlePageProps = {
   comments: Comment[];
   post: Article;
+  slug: string;
   translation: Messages;
 };
 
@@ -49,26 +51,29 @@ type ArticlePageProps = {
 const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
   comments,
   post,
+  slug,
 }) => {
-  const { content, id, intro, meta, slug, title } = post;
-  const {
-    author,
-    commentsCount,
-    cover,
-    dates,
-    seo,
-    thematics,
-    topics,
-    wordsCount,
-  } = meta;
-  const { data } = useSWR(() => id, getPostComments, {
+  const { isFallback } = useRouter();
+  const intl = useIntl();
+  const { data: article } = useSWR(() => slug, getArticleBySlug, {
+    fallbackData: post,
+  });
+  const { data: commentsData } = useSWR(() => id, getPostComments, {
     fallbackData: comments,
   });
   const { items: breadcrumbItems, schema: breadcrumbSchema } = useBreadcrumb({
-    title,
+    title: article?.title || '',
     url: `/article/${slug}`,
   });
-  const readingTime = useReadingTime(wordsCount, true);
+  const readingTime = useReadingTime(article?.meta.wordsCount || 0, true);
+  const { website } = useSettings();
+  const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers'];
+  const { attributes, className } = usePrism({ plugins: prismPlugins });
+
+  if (isFallback) return <Spinner />;
+
+  const { content, id, intro, meta, title } = article!;
+  const { author, commentsCount, cover, dates, seo, thematics, topics } = meta;
 
   const headerMeta: PageLayoutProps['headerMeta'] = {
     author: author?.name,
@@ -87,7 +92,6 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
       )),
   };
 
-  const intl = useIntl();
   const footerMetaLabel = intl.formatMessage({
     defaultMessage: 'Read more articles about:',
     description: 'ArticlePage: footer topics list label',
@@ -107,19 +111,17 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     },
   };
 
-  const { website } = useSettings();
-  const { asPath } = useRouter();
   const webpageSchema = getWebPageSchema({
     description: intro,
     locale: website.locales.default,
-    slug: asPath,
+    slug,
     title,
     updateDate: dates.update,
   });
   const blogSchema = getBlogSchema({
     isSinglePage: true,
     locale: website.locales.default,
-    slug: asPath,
+    slug,
   });
   const blogPostSchema = getSinglePageSchema({
     commentsCount,
@@ -130,7 +132,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     id: 'article',
     kind: 'post',
     locale: website.locales.default,
-    slug: asPath,
+    slug,
     title,
   });
   const schemaJsonLd = getSchemaJson([
@@ -139,8 +141,6 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     blogPostSchema,
   ]);
 
-  const prismPlugins: OptionalPrismPlugin[] = ['command-line', 'line-numbers'];
-  const { attributes, className } = usePrism({ plugins: prismPlugins });
   const lineNumbersClassName = className
     .replace('command-line', '')
     .replace(/\s\s+/g, ' ');
@@ -174,7 +174,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
     prismClassNameReplacer
   );
 
-  const pageUrl = `${website.url}${asPath}`;
+  const pageUrl = `${website.url}${slug}`;
 
   return (
     <>
@@ -199,7 +199,7 @@ const ArticlePage: NextPageWithLayout<ArticlePageProps> = ({
         bodyClassName={styles.body}
         breadcrumb={breadcrumbItems}
         breadcrumbSchema={breadcrumbSchema}
-        comments={data}
+        comments={commentsData}
         footerMeta={footerMeta}
         headerMeta={headerMeta}
         id={id as number}
@@ -246,6 +246,7 @@ export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({
     props: {
       comments: JSON.parse(JSON.stringify(comments)),
       post: JSON.parse(JSON.stringify(post)),
+      slug: post.slug,
       translation,
     },
   };
@@ -259,7 +260,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
 
   return {
     paths,
-    fallback: false,
+    fallback: true,
   };
 };
 
-- 
cgit v1.2.3

